HarmonyOS 应用实战:校园树洞投稿(一)首页——话题横滑与树洞卡片流
发布时间:2026/10/2 17:25:08来源:尧图网络
HarmonyOS 应用实战校园树洞投稿一首页——话题横滑与树洞卡片流项目编号42-treehole-post 技术栈HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面首页HomeTab.ets100 行 系列导航一首页· 二投稿 · 三话题 · 四我的 源码开源https://gitee.com/codenestFlow/HarmonyOSHub一、本篇聚焦「校园树洞投稿」是一个匿名情绪倾诉应用底部四个 Tab首页、投稿、话题、我的。首页承担内容消费职责——上面一条话题横滑带做分类导航下面一组树洞卡片做正文流。本篇从三个问题切入数据怎么建模、横滑与纵滑如何共存、卡片上的两个数字 抱抱 / 回复如何从静态文字变成可交互状态。二、数据模型一条树洞需要几个字段interface Hole { id: number; content: string; topic: string; time: string; replies: number; warm: number; }六个字段对应六个职责值得逐个对照 UI 看字段类型渲染位置设计说明idnumberForEach的 key差分更新的身份锚点禁止用 indexcontentstring正文 15 号 / 行高 24树洞的主体不折叠topicstring# 话题紫标签跨三页共享的词表见第四节timestring右上角 11 号灰字展示层字符串见第八节的演进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 三个抱抱量级、负面—焦虑—正向三种情绪一个静态数组就覆盖了卡片在极端值下的排版表现无需反复改数据验证换行。三、页面骨架固定 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的第一个子节点不参与滚动天然固定。若把它放进Scroll标题会随内容滚走。layoutWeight(1)抢占剩余高度——Column主轴是纵向Scroll拿到 Header 之外的全部高度配合.scrollBar(BarState.Off)隐藏滚动条视觉更干净。底部内边距D.pad this.safeBottom 20—— 16 手势条高度 20保证最后一张卡片不会被底部导航条压住这是全系列统一的收尾公式。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浅紫承担所有话题胶囊底色。四、安全区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)); }链路是沉浸式全屏 → 读取避让区 →px2vp转逻辑像素 → 写入AppStorage→ 页面StorageProp订阅。三点必须注意沉浸式必须在onWindowStageCreate里做不能写在页面aboutToAppear——那时窗口还没就绪。getWindowAvoidArea返回的是物理像素必须px2vp转换否则在 3x 密度设备上会偏大一倍。StorageProp是单向同步页面读全局值本地修改不回写。安全区这种全局只读数据正合适若需要双向写入如 Tab 索引用StorageLink。于是 Header 只需把安全区加到 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 按手势主方向分派事件斜滑也不会两层同时响应。如果内层忘了限定方向就会出现上下滑时话题区自己滚的诡异手感。胶囊的几何也值得抄padding上下 8 13 号字 → 高度约 37vpborderRadius(16)是高度的一半多一点得到标准胶囊形backgroundColor(C.primarySoft)浅紫底 C.primary紫字是 TopicCard 未选中态的反色版本。演进给胶囊加选中态并联动过滤。// 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用话题名字符串而不是索引字符串是三个页面共享的稳定标识索引只在某个数组内部有意义。列表渲染改用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上—— 整卡可点热区覆盖标签、正文、互动条注意内层要width(100%)撑满否则卡片右侧留白区点不到。卡片上没有作者这是树洞和表白墙最本质的差别连匿名同学这样的马甲都省掉读者看到的是一段情绪而不是某个人倾诉门槛更低。七、让数字动起来抱抱的不可变更新Demo 里 抱抱 89是死的。要让它可点关键不在 UI而在状态更新的姿势// ❌ 错误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对数组的观察是引用级的——改元素属性不触发刷新必须让数组本身变成新引用。slice(0, idx).concat([next], slice(idx1))是 ArkTS 里最稳的替换第 idx 项写法。hugged布尔位不可省——没有它用户连点十次就 10有了它第二次点击是撤回? -1 : 1。字段逐个展开而非{...old}——在 ArkTS 严格模式下显式构造对象能让类型检查一路通到编译期合并对象时漏字段会直接报错而不是运行时 undefined。配套 UI 只需在互动条上加一个点击区Row({ space: 5 }) { Text().fontSize(15) Text(抱抱 h.warm).fontSize(13) .fontColor(h.hugged ? C.primary : C.textSub) // 抱过变紫 } .onClick(() { this.hug(h.id); })抱过之后数字变主色紫既是状态回显也是你已经安慰过 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里的卡片开始有状态抱抱、收藏、展开全文继续堆在Builder里会让HomeTab迅速膨胀。抽成组件后卡片自带状态父页面只管传数据// 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统计端按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; // 仅用于触发重绘 State holes: Hole[] HoleStore.all(); aboutToAppear(): void { this.holes HoleStore.all(); // 每次回到首页都拉最新 }HoleStore是普通类而不是State数据只有一个副本页面负责订阅与重绘。这比把数组在四个页面间用路由参数传来传去要可靠得多。十一、实机验证四张截图对应四个验证点1. 首屏标题栏固定 话题横滑一行五个第五个# 治愈瞬间被屏宽截断提示可滑 第一条树洞卡。2. 点话题胶囊→ Toast 显示话题名。每个胶囊的onClick闭包捕获各自的t点击参数传递正确。3. 点树洞卡→ Toast「进入树洞」。整卡热区生效点击正文、标签、互动条任一位置都触发。4. 滚到底部第三条树洞# 治愈瞬间 / 1 小时前 / 抱抱 203完整可见底部安全区留白足够。验收清单项验证方式结果横纵滚动正交在话题区横向快速滑动页面不纵向滚动胶囊截断暗示首屏第五个胶囊部分可见预期Blank()右推时间贴右边缘正常底部安全区滚到底最后一张卡未被导航条遮挡ForEachkeyh.id.toString()唯一稳定十二、可演进方向方向现在的写法改法收益话题过滤Toast 占位activeTopicfilter横滑成为真过滤器抱抱交互静态数字toggleHug不可变更新可点、可撤回时间显示写死字符串tsrelTime()可排序、不失真卡片复用内联Builder抽出HoleCard话题详情页直接复用长文折叠全文展示maxLines(4) 展开避免单条刷屏空态无if (this.holes.length 0)过滤无结果时有交代十三、本篇小结首页 100 行做了三件事Header固定在Scroll外、Scroll用layoutWeight(1)吃掉剩余高度、卡片用Blank()完成两端对齐。真正决定它能不能活起来的是第七节的不可变更新与第十节的数据仓库——UI 只是数据的投影。下一篇 二投稿 讲生产端三个State如何编排成一表单以及投稿成功后怎么让首页立刻看到新树洞。
网站建设高端定制企业官网