☰
HarmonyOS 应用实战:校园树洞投稿(一)首页——话题横滑与树洞卡片流
2026/10/2 17:25:03 网站建设 项目流程

HarmonyOS 应用实战:校园树洞投稿(一)首页——话题横滑与树洞卡片流

项目编号:42-treehole-post 技术栈:HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面:首页HomeTab.ets(100 行) 系列导航:(一)首页· (二)投稿 · (三)话题 · (四)我的 源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

一、本篇聚焦

「校园树洞投稿」是一个匿名情绪倾诉应用,底部四个 Tab:首页(🌳)、投稿(📝)、话题(🔖)、我的(👤)。首页承担内容消费职责——上面一条话题横滑带做分类导航,下面一组树洞卡片做正文流。

本篇从三个问题切入:数据怎么建模、横滑与纵滑如何共存、卡片上的两个数字(🫂 抱抱 / 💬 回复)如何从静态文字变成可交互状态。

二、数据模型:一条树洞需要几个字段

interface Hole { id: number; content: string; topic: string; time: string; replies: number; warm: number; }

六个字段对应六个职责,值得逐个对照 UI 看:

字段类型渲染位置设计说明
idnumberForEach的 key差分更新的身份锚点,禁止用 index
contentstring正文 15 号 / 行高 24树洞的主体,不折叠
topicstring# 话题紫标签跨三页共享的词表,见第四节
timestring右上角 11 号灰字展示层字符串,见第八节的演进
repliesnumber💬 N互动指标 A
warmnumber🫂 抱抱 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 + 自适应 Scroll

build() { 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) }

三个决定结构的关键点:

  1. Header 在Scroll外—— 标题栏是Column的第一个子节点,不参与滚动,天然固定。若把它放进Scroll,标题会随内容滚走。
  2. layoutWeight(1)抢占剩余高度——Column主轴是纵向,Scroll拿到 Header 之外的全部高度;配合.scrollBar(BarState.Off)隐藏滚动条,视觉更干净。
  3. 底部内边距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 阶段就写进了AppStorage:

try { 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订阅。

三点必须注意:

  1. 沉浸式必须在onWindowStageCreate里做,不能写在页面aboutToAppear——那时窗口还没就绪。
  2. getWindowAvoidArea返回的是物理像素,必须px2vp转换,否则在 3x 密度设备上会偏大一倍。
  3. @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 号字 → 高度约 37vp,borderRadius(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%') }

拆解成四个可复用的手法:

  1. Blank()做两端对齐——Row里Blank()吃掉剩余宽度,把时间推到最右。比justifyContent(FlexAlign.SpaceBetween)更灵活:中间还能插第三个元素。
  2. 圆角即语义—— 话题标签borderRadius(6)近矩形 = "这是数据的归属",可点胶囊borderRadius(16)= "这是入口"。同一套紫色,两种圆角,用户不看文档也能分辨。
  3. lineHeight(24)—— 15 号字配 24 行高(1.6 倍),比默认行高更适合长段落阅读。正文类卡片务必显式设置,否则多行文本会显得挤。
  4. 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)); }

要点三条:

  1. @State对数组的观察是"引用级"的——改元素属性不触发刷新,必须让数组本身变成新引用。slice(0, idx).concat([next], slice(idx+1))是 ArkTS 里最稳的"替换第 idx 项"写法。
  2. hugged布尔位不可省——没有它,用户连点十次就 +10;有了它,第二次点击是撤回(? -1 : 1)。
  3. 字段逐个展开而非{...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.get<number>('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 占位activeTopic+filter横滑成为真过滤器
抱抱交互静态数字toggleHug不可变更新可点、可撤回
时间显示写死字符串ts+relTime()可排序、不失真
卡片复用内联@Builder抽出HoleCard话题详情页直接复用
长文折叠全文展示maxLines(4)+ 展开避免单条刷屏
空态无if (this.holes.length === 0)过滤无结果时有交代

十三、本篇小结

首页 100 行做了三件事:Header固定在Scroll外、Scroll用layoutWeight(1)吃掉剩余高度、卡片用Blank()完成两端对齐。真正决定它能不能"活"起来的,是第七节的不可变更新与第十节的数据仓库——UI 只是数据的投影。

下一篇 (二)投稿 讲生产端:三个@State如何编排成一表单,以及投稿成功后怎么让首页立刻看到新树洞。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询