用實(shí)戰(zhàn):校園樹洞投稿(一)首頁——話題橫滑與樹洞卡片流)
HarmonyOS 應(yīng)用實(shí)戰(zhàn)校園樹洞投稿一首頁——話題橫滑與樹洞卡片流項(xiàng)目編號42-treehole-post 技術(shù)棧HarmonyOS ArkTS · ArkUI 聲明式 UI 本篇頁面首頁HomeTab.ets100 行 系列導(dǎo)航一首頁· 二投稿 · 三話題 · 四我的 源碼開源https://gitee.com/codenestFlow/HarmonyOSHub一、本篇聚焦「校園樹洞投稿」是一個(gè)匿名情緒傾訴應(yīng)用底部四個(gè) Tab首頁、投稿、話題、我的。首頁承擔(dān)內(nèi)容消費(fèi)職責(zé)——上面一條話題橫滑帶做分類導(dǎo)航下面一組樹洞卡片做正文流。本篇從三個(gè)問題切入數(shù)據(jù)怎么建模、橫滑與縱滑如何共存、卡片上的兩個(gè)數(shù)字 抱抱 / 回復(fù)如何從靜態(tài)文字變成可交互狀態(tài)。二、數(shù)據(jù)模型一條樹洞需要幾個(gè)字段interface Hole { id: number; content: string; topic: string; time: string; replies: number; warm: number; }六個(gè)字段對應(yīng)六個(gè)職責(zé)值得逐個(gè)對照 UI 看字段類型渲染位置設(shè)計(jì)說明idnumberForEach的 key差分更新的身份錨點(diǎn)禁止用 indexcontentstring正文 15 號 / 行高 24樹洞的主體不折疊topicstring# 話題紫標(biāo)簽跨三頁共享的詞表見第四節(jié)timestring右上角 11 號灰字展示層字符串見第八節(jié)的演進(jìn)repliesnumber N互動(dòng)指標(biāo) Awarmnumber 抱抱 N互動(dòng)指標(biāo) B樹洞的特色動(dòng)詞三條種子數(shù)據(jù)刻意覆蓋了三種情緒方向private hotTopics: string[] [深夜emo, 學(xué)習(xí)焦慮, 人際關(guān)系, 青春疼痛, 治愈瞬間]; private holes: Hole[] [ { id: 1, content: 一個(gè)人在異地讀書有時(shí)候真的很想家。但看到窗外的月亮又覺得家人也在同一片天空下 , topic: 深夜emo, time: 10分鐘前, replies: 24, warm: 89 }, { id: 2, content: 考研倒計(jì)時(shí)100天壓力好大但我不想放棄。寫在這里給自己打氣你一定可以的, topic: 學(xué)習(xí)焦慮, time: 30分鐘前, replies: 56, warm: 156 }, { id: 3, content: 今天在公交車上給老奶奶讓座她夸我是個(gè)好孩子突然心里暖暖的 ??, topic: 治愈瞬間, time: 1小時(shí)前, replies: 18, warm: 203 } ];這也是一種數(shù)據(jù)即測試用例10 分鐘 / 30 分鐘 / 1 小時(shí)三個(gè)時(shí)間梯度、89 / 156 / 203 三個(gè)抱抱量級、負(fù)面—焦慮—正向三種情緒一個(gè)靜態(tài)數(shù)組就覆蓋了卡片在極端值下的排版表現(xiàn)無需反復(fù)改數(shù)據(jù)驗(yàn)證換行。三、頁面骨架固定 Header 自適應(yīng) Scrollbuild() { Column() { this.Header() Scroll() { Column({ space: 14 }) { this.TopicScroll() this.SectionTitle(最新樹洞) this.HoleList() } .width(100%) .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad this.safeBottom 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width(100%).height(100%).backgroundColor(C.bg) }三個(gè)決定結(jié)構(gòu)的關(guān)鍵點(diǎn)Header 在Scroll外—— 標(biāo)題欄是Column的第一個(gè)子節(jié)點(diǎn)不參與滾動(dòng)天然固定。若把它放進(jìn)Scroll標(biāo)題會隨內(nèi)容滾走。layoutWeight(1)搶占剩余高度——Column主軸是縱向Scroll拿到 Header 之外的全部高度配合.scrollBar(BarState.Off)隱藏滾動(dòng)條視覺更干凈。底部內(nèi)邊距D.pad this.safeBottom 20—— 16 手勢條高度 20保證最后一張卡片不會被底部導(dǎo)航條壓住這是全系列統(tǒng)一的收尾公式。D.pad與C.bg來自全局主題Theme.ets把顏色和尺寸分成兩個(gè)類export class C { static readonly bg: string #F2F5FB; static readonly card: string #FFFFFF; static readonly cardSoft: string #F4F6FB; static readonly stroke: string #EAEEF6; static readonly text: string #1C2333; static readonly textSub: string #5A6483; static readonly textDim: string #9AA3BC; static readonly primary: string #6C5CE7; static readonly primarySoft: string #ECE8FD; static readonly accent: string #9B8AF5; static readonly warn: string #FF9F1C; static readonly danger: string #FF5A6E; static readonly ok: string #2BB673; }C是顏色primary主色紫#6C5CE7、D是尺寸rLg/rMd/rSm/pad。頁面里不出現(xiàn)任何硬編碼色值換膚只改這一個(gè)文件——C.ok綠在投稿頁承擔(dān) 完全匿名的安全語義C.primarySoft淺紫承擔(dān)所有話題膠囊底色。四、安全區(qū)safeTop的來龍去脈頁面里this.safeTop是StorageProp它的值在 Ability 階段就寫進(jìn)了AppStoragetry { const win windowStage.getMainWindowSync(); win.setWindowLayoutFullScreen(true); win.setWindowSystemBarProperties({ statusBarContentColor: #1C2333, navigationBarContentColor: #1C2333 }); const top win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); const bottom win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); AppStorage.setOrCreate(safeTop, px2vp(top.topRect.height)); AppStorage.setOrCreate(safeBottom, px2vp(bottom.bottomRect.height)); } catch (e) { hilog.error(DOMAIN, TAG, window setup failed: %{public}s, JSON.stringify(e)); }鏈路是沉浸式全屏 → 讀取避讓區(qū) →px2vp轉(zhuǎn)邏輯像素 → 寫入AppStorage→ 頁面StorageProp訂閱。三點(diǎn)必須注意沉浸式必須在onWindowStageCreate里做不能寫在頁面aboutToAppear——那時(shí)窗口還沒就緒。getWindowAvoidArea返回的是物理像素必須px2vp轉(zhuǎn)換否則在 3x 密度設(shè)備上會偏大一倍。StorageProp是單向同步頁面讀全局值本地修改不回寫。安全區(qū)這種全局只讀數(shù)據(jù)正合適若需要雙向?qū)懭肴?Tab 索引用StorageLink。于是 Header 只需把安全區(qū)加到 padding 上不需知道設(shè)備型號Builder Header() { Column() { Text( 樹洞).fontSize(22).fontWeight(FontWeight.Bold).fontColor(C.text) .width(100%).padding({ top: this.safeTop 10, left: D.pad, right: D.pad, bottom: 4 }) Text(把秘密說給樹洞聽).fontSize(12).fontColor(C.textDim).width(100%) .padding({ left: D.pad, right: D.pad, bottom: 12 }) }.width(100%).backgroundColor(C.card) }標(biāo)題把 直接寫進(jìn)字符串省一個(gè) Image 組件副標(biāo)題一句話講清產(chǎn)品用途。兩層padding的bottom分別是 4 和 12形成主標(biāo)題緊、副標(biāo)題松的層次。五、熱門話題嵌套滾動(dòng)的正交原則Builder TopicScroll() { Column({ space: 10 }) { Text( 熱門話題).fontSize(14).fontWeight(FontWeight.Bold).fontColor(C.text).width(100%) Scroll() { Row({ space: 10 }) { ForEach(this.hotTopics, (t: string) { Text(# t).fontSize(13).fontColor(C.primary) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(C.primarySoft).borderRadius(16) .onClick(() { promptAction.showToast({ message: t }); }) }, (t: string) t) } }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width(100%) } }為什么橫滑而不是換行5 個(gè)話題占兩行會擠掉樹洞列表的縱向空間橫滑只占一行高度約 45vp且右側(cè)膠囊被截?cái)啾旧砭褪呛竺孢€有的視覺暗示。嵌套滾動(dòng)不打架的前提是方向正交外層Scroll縱向、內(nèi)層Scroll顯式scrollable(ScrollDirection.Horizontal)ArkUI 按手勢主方向分派事件斜滑也不會兩層同時(shí)響應(yīng)。如果內(nèi)層忘了限定方向就會出現(xiàn)上下滑時(shí)話題區(qū)自己滾的詭異手感。膠囊的幾何也值得抄padding上下 8 13 號字 → 高度約 37vpborderRadius(16)是高度的一半多一點(diǎn)得到標(biāo)準(zhǔn)膠囊形backgroundColor(C.primarySoft)淺紫底 C.primary紫字是 TopicCard 未選中態(tài)的反色版本。演進(jìn)給膠囊加選中態(tài)并聯(lián)動(dòng)過濾。// HomeTab.ets —— 話題從「裝飾」升級為「過濾器」 State activeTopic: string ; // 空串 全部 Builder TopicChips() { Scroll() { Row({ space: 10 }) { ForEach(this.hotTopics, (t: string) { Text(# t) .fontSize(13) .fontColor(this.activeTopic t ? #FFFFFF : C.primary) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.activeTopic t ? C.primary : C.primarySoft) .borderRadius(16) .onClick(() { this.activeTopic this.activeTopic t ? : t; // 再點(diǎn)一次取消 }) }, (t: string) t) } }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width(100%) } private visibleHoles(): Hole[] { if (this.activeTopic ) { return this.holes; } return this.holes.filter((h: Hole) h.topic this.activeTopic); }activeTopic用話題名字符串而不是索引字符串是三個(gè)頁面共享的穩(wěn)定標(biāo)識索引只在某個(gè)數(shù)組內(nèi)部有意義。列表渲染改用visibleHoles()過濾邏輯留在數(shù)據(jù)層UI 只負(fù)責(zé)畫。六、樹洞卡片Blank()撐開的兩端對齊Builder HoleList() { Column({ space: 12 }) { ForEach(this.holes, (h: Hole) { Column({ space: 12 }) { Row() { Text(# h.topic).fontSize(12).fontColor(C.primary) .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(C.primarySoft).borderRadius(6) Blank() Text(h.time).fontSize(11).fontColor(C.textDim) }.width(100%) Text(h.content).fontSize(15).fontColor(C.text).lineHeight(24).width(100%) Row({ space: 20 }) { Row({ space: 5 }) { Text().fontSize(15); Text(抱抱 h.warm).fontSize(13).fontColor(C.textSub) } Row({ space: 5 }) { Text().fontSize(15); Text(h.replies.toString()).fontSize(13).fontColor(C.textSub) } }.width(100%) } .width(100%).padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) .onClick(() { promptAction.showToast({ message: 進(jìn)入樹洞 }); }) }, (h: Hole) h.id.toString()) }.width(100%) }拆解成四個(gè)可復(fù)用的手法Blank()做兩端對齊——Row里Blank()吃掉剩余寬度把時(shí)間推到最右。比justifyContent(FlexAlign.SpaceBetween)更靈活中間還能插第三個(gè)元素。圓角即語義—— 話題標(biāo)簽borderRadius(6)近矩形 這是數(shù)據(jù)的歸屬可點(diǎn)膠囊borderRadius(16) 這是入口。同一套紫色兩種圓角用戶不看文檔也能分辨。lineHeight(24)—— 15 號字配 24 行高1.6 倍比默認(rèn)行高更適合長段落閱讀。正文類卡片務(wù)必顯式設(shè)置否則多行文本會顯得擠。onClick掛在內(nèi)層Column上—— 整卡可點(diǎn)熱區(qū)覆蓋標(biāo)簽、正文、互動(dòng)條注意內(nèi)層要width(100%)撐滿否則卡片右側(cè)留白區(qū)點(diǎn)不到??ㄆ蠜]有作者這是樹洞和表白墻最本質(zhì)的差別連匿名同學(xué)這樣的馬甲都省掉讀者看到的是一段情緒而不是某個(gè)人傾訴門檻更低。七、讓數(shù)字動(dòng)起來抱抱的不可變更新Demo 里 抱抱 89是死的。要讓它可點(diǎn)關(guān)鍵不在 UI而在狀態(tài)更新的姿勢// ? 錯(cuò)誤State 觀察不到數(shù)組元素內(nèi)部屬性的變化 private hugWrong(id: number): void { const h: Hole this.holes[0]; h.warm h.warm 1; // 數(shù)組引用沒變UI 不刷新 } // ? 正確生成新對象 替換數(shù)組引用 private hug(id: number): void { const idx: number this.holes.findIndex((h: Hole) h.id id); if (idx 0) { return; } const old: Hole this.holes[idx]; const next: Hole { id: old.id, content: old.content, topic: old.topic, time: old.time, replies: old.replies, warm: old.warm (old.hugged ? -1 : 1), // 已抱過則撤回 hugged: !old.hugged, mine: old.mine }; this.holes this.holes.slice(0, idx).concat([next], this.holes.slice(idx 1)); }要點(diǎn)三條State對數(shù)組的觀察是引用級的——改元素屬性不觸發(fā)刷新必須讓數(shù)組本身變成新引用。slice(0, idx).concat([next], slice(idx1))是 ArkTS 里最穩(wěn)的替換第 idx 項(xiàng)寫法。hugged布爾位不可省——沒有它用戶連點(diǎn)十次就 10有了它第二次點(diǎn)擊是撤回? -1 : 1。字段逐個(gè)展開而非{...old}——在 ArkTS 嚴(yán)格模式下顯式構(gòu)造對象能讓類型檢查一路通到編譯期合并對象時(shí)漏字段會直接報(bào)錯(cuò)而不是運(yùn)行時(shí) undefined。配套 UI 只需在互動(dòng)條上加一個(gè)點(diǎn)擊區(qū)Row({ space: 5 }) { Text().fontSize(15) Text(抱抱 h.warm).fontSize(13) .fontColor(h.hugged ? C.primary : C.textSub) // 抱過變紫 } .onClick(() { this.hug(h.id); })抱過之后數(shù)字變主色紫既是狀態(tài)回顯也是你已經(jīng)安慰過 TA的情緒確認(rèn)。八、時(shí)間顯示從寫死的字符串到相對時(shí)間函數(shù)time: 10分鐘前是展示層數(shù)據(jù)一旦數(shù)據(jù)停留超過一小時(shí)就失真。把字段換成時(shí)間戳顯示交給純函數(shù)// common/TimeUtil.ets export function relTime(ts: number, now: number Date.now()): string { const diff: number Math.floor((now - ts) / 1000); if (diff 60) { return 剛剛; } if (diff 3600) { return Math.floor(diff / 60) 分鐘前; } if (diff 86400) { return Math.floor(diff / 3600) 小時(shí)前; } if (diff 86400 * 7) { return Math.floor(diff / 86400) 天前; } const d: Date new Date(ts); return (d.getMonth() 1) 月 d.getDate() 日; }改造后Hole的time: string換成ts: number卡片里Text(relTime(h.ts))。收益有三個(gè)數(shù)據(jù)可排序按ts倒序即最新在前、顯示永遠(yuǎn)準(zhǔn)確、超過一周自動(dòng)退化成日期。now作為參數(shù)傳入而不是在函數(shù)內(nèi)直接取是為了讓這個(gè)函數(shù)可測試——relTime(ts, ts 600000)恒等于10分鐘前。九、抽組件把卡片隔離成獨(dú)立Component當(dāng)HoleList里的卡片開始有狀態(tài)抱抱、收藏、展開全文繼續(xù)堆在Builder里會讓HomeTab迅速膨脹。抽成組件后卡片自帶狀態(tài)父頁面只管傳數(shù)據(jù)// components/HoleCard.ets Component export struct HoleCard { Prop hole: Hole; // 父傳子單向 onHug: (id: number) void () {}; // 行為由父注入卡片不碰數(shù)據(jù)源 build() { Column({ space: 12 }) { Row() { Text(# this.hole.topic).fontSize(12).fontColor(C.primary) .padding({ left: 8, right: 8, top: 3, bottom: 3 }) .backgroundColor(C.primarySoft).borderRadius(6) Blank() Text(relTime(this.hole.ts)).fontSize(11).fontColor(C.textDim) }.width(100%) Text(this.hole.content).fontSize(15).fontColor(C.text).lineHeight(24).width(100%) Row({ space: 20 }) { Row({ space: 5 }) { Text().fontSize(15) Text(抱抱 this.hole.warm).fontSize(13) .fontColor(this.hole.hugged ? C.primary : C.textSub) }.onClick(() { this.onHug(this.hole.id); }) Row({ space: 5 }) { Text().fontSize(15) Text(this.hole.replies.toString()).fontSize(13).fontColor(C.textSub) } }.width(100%) } .width(100%).padding(16).backgroundColor(C.card) .borderRadius(D.rLg).border({ width: 1, color: C.stroke }) } }Prop保證數(shù)據(jù)單向流動(dòng)父頁面持有唯一真相卡片只渲染 通過onHug回調(diào)上報(bào)意圖。父頁面用法ForEach(this.holes, (h: Hole) { HoleCard({ hole: h, onHug: (id: number) this.hug(id) }) }, (h: Hole) h.id.toString())這一抽離直接帶來第二篇和第三篇的復(fù)用話題詳情頁按話題過濾后渲染的還是同一個(gè)HoleCard。十、四頁之間的數(shù)據(jù)流首頁不是孤立的它是整套數(shù)據(jù)流的消費(fèi)端。把四頁串起來看頁面文件角色與Hole的關(guān)系首頁HomeTab.ets消費(fèi)端讀全部 / 按話題過濾展示最新投稿Func1Tab.ets生產(chǎn)端組裝content topic mood寫入話題Func2Tab.ets目錄端按topic聚合出count我的ProfileTab.ets統(tǒng)計(jì)端按mine聚合出投稿數(shù) / 抱抱數(shù)支撐這條鏈路的最小倉庫本篇只用到all()、byTopic()、toggleHug()其余三篇各取所需// common/HoleStore.ets export interface Hole { id: number; content: string; topic: string; mood: string; // ts: number; // 投稿時(shí)間戳 replies: number; warm: number; hugged: boolean; mine: boolean; } const NOW: number Date.now(); const SEED: Hole[] [ { id: 1, content: 一個(gè)人在異地讀書…… , topic: 深夜emo, mood: , ts: NOW - 10 * 60 * 1000, replies: 24, warm: 89, hugged: false, mine: false }, { id: 2, content: 考研倒計(jì)時(shí)100天……, topic: 學(xué)習(xí)焦慮, mood: , ts: NOW - 30 * 60 * 1000, replies: 56, warm: 156, hugged: false, mine: false }, { id: 3, content: 今天在公交車上給老奶奶讓座…… ??, topic: 治愈瞬間, mood: , ts: NOW - 60 * 60 * 1000, replies: 18, warm: 203, hugged: false, mine: false } ]; export class HoleStore { private static list: Hole[] SEED; static all(): Hole[] { return HoleStore.list; } static byTopic(topic: string): Hole[] { return HoleStore.list.filter((h: Hole) h.topic topic); } static publish(content: string, topic: string, mood: string): Hole { const hole: Hole { id: Date.now(), content: content, topic: topic, mood: mood, ts: Date.now(), replies: 0, warm: 0, hugged: false, mine: true }; HoleStore.list [hole, ...HoleStore.list]; // 新投稿置頂 return hole; } static toggleHug(id: number): Hole | undefined { const idx: number HoleStore.list.findIndex((h: Hole) h.id id); if (idx 0) { return undefined; } const old: Hole HoleStore.list[idx]; const next: Hole { id: old.id, content: old.content, topic: old.topic, mood: old.mood, ts: old.ts, replies: old.replies, warm: old.warm (old.hugged ? -1 : 1), hugged: !old.hugged, mine: old.mine }; HoleStore.list HoleStore.list.slice(0, idx).concat([next], HoleStore.list.slice(idx 1)); return next; } // 通知所有訂閱頁刷新AppStorage 里的版本號 1 static bump(): void { const v: number AppStorage.getnumber(holeVersion) ?? 0; AppStorage.setOrCreate(holeVersion, v 1); } }首頁訂閱版本號即可在數(shù)據(jù)變化后重繪StorageProp(holeVersion) version: number 0; // 僅用于觸發(fā)重繪 State holes: Hole[] HoleStore.all(); aboutToAppear(): void { this.holes HoleStore.all(); // 每次回到首頁都拉最新 }HoleStore是普通類而不是State數(shù)據(jù)只有一個(gè)副本頁面負(fù)責(zé)訂閱與重繪。這比把數(shù)組在四個(gè)頁面間用路由參數(shù)傳來傳去要可靠得多。十一、實(shí)機(jī)驗(yàn)證四張截圖對應(yīng)四個(gè)驗(yàn)證點(diǎn)1. 首屏標(biāo)題欄固定 話題橫滑一行五個(gè)第五個(gè)# 治愈瞬間被屏寬截?cái)嗵崾究苫?第一條樹洞卡。2. 點(diǎn)話題膠囊→ Toast 顯示話題名。每個(gè)膠囊的onClick閉包捕獲各自的t點(diǎn)擊參數(shù)傳遞正確。3. 點(diǎn)樹洞卡→ Toast「進(jìn)入樹洞」。整卡熱區(qū)生效點(diǎn)擊正文、標(biāo)簽、互動(dòng)條任一位置都觸發(fā)。4. 滾到底部第三條樹洞# 治愈瞬間 / 1 小時(shí)前 / 抱抱 203完整可見底部安全區(qū)留白足夠。驗(yàn)收清單項(xiàng)驗(yàn)證方式結(jié)果橫縱滾動(dòng)正交在話題區(qū)橫向快速滑動(dòng)頁面不縱向滾動(dòng)膠囊截?cái)喟凳臼灼恋谖鍌€(gè)膠囊部分可見預(yù)期Blank()右推時(shí)間貼右邊緣正常底部安全區(qū)滾到底最后一張卡未被導(dǎo)航條遮擋ForEachkeyh.id.toString()唯一穩(wěn)定十二、可演進(jìn)方向方向現(xiàn)在的寫法改法收益話題過濾Toast 占位activeTopicfilter橫滑成為真過濾器抱抱交互靜態(tài)數(shù)字toggleHug不可變更新可點(diǎn)、可撤回時(shí)間顯示寫死字符串tsrelTime()可排序、不失真卡片復(fù)用內(nèi)聯(lián)Builder抽出HoleCard話題詳情頁直接復(fù)用長文折疊全文展示maxLines(4) 展開避免單條刷屏空態(tài)無if (this.holes.length 0)過濾無結(jié)果時(shí)有交代十三、本篇小結(jié)首頁 100 行做了三件事Header固定在Scroll外、Scroll用layoutWeight(1)吃掉剩余高度、卡片用Blank()完成兩端對齊。真正決定它能不能活起來的是第七節(jié)的不可變更新與第十節(jié)的數(shù)據(jù)倉庫——UI 只是數(shù)據(jù)的投影。下一篇 二投稿 講生產(chǎn)端三個(gè)State如何編排成一表單以及投稿成功后怎么讓首頁立刻看到新樹洞。