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 看:
| 字段 | 类型 | 渲染位置 | 设计说明 |
|---|---|---|---|
id | number | ForEach的 key | 差分更新的身份锚点,禁止用 index |
content | string | 正文 15 号 / 行高 24 | 树洞的主体,不折叠 |
topic | string | # 话题紫标签 | 跨三页共享的词表,见第四节 |
time | string | 右上角 11 号灰字 | 展示层字符串,见第八节的演进 |
replies | number | 💬 N | 互动指标 A |
warm | number | 🫂 抱抱 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) }三个决定结构的关键点:
- 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 阶段就写进了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订阅。
三点必须注意:
- 沉浸式必须在
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 号字 → 高度约 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%') }拆解成四个可复用的手法:
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(idx+1))是 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.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()右推 | 时间贴右边缘 | 正常 |
| 底部安全区 | 滚到底最后一张卡 | 未被导航条遮挡 |
ForEachkey | h.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如何编排成一表单,以及投稿成功后怎么让首页立刻看到新树洞。