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