尧图精选

HarmonyOS 应用实战:校园树洞投稿(三)话题——Grid 两列网格与热度聚合

🕒 发布时间:2026/10/2 12:53:30 📁 来源:尧图网络
HarmonyOS 应用实战校园树洞投稿三话题——Grid 两列网格与热度聚合项目编号42-treehole-post 技术栈HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面话题Func2Tab.ets70 行 系列导航一首页 · 二投稿 ·三话题· 四我的 源码开源https://gitee.com/codenestFlow/HarmonyOSHub一、本篇聚焦在四页数据流里见 一首页 第十节话题页是目录端它不展示树洞正文而是把内容按topic字段重新组织成入口count字段就是这里的热度。本篇解决四个问题Grid的columnsTemplate怎么算列宽、count怎么格式化和排序、静态数组怎么升级为按HoleStore实时聚合、点击话题格子之后怎么跳到只属于它的话题详情页。二、数据模型目录项的五个字段interface Topic { id: number; emoji: string; name: string; desc: string; count: number; }字段渲染位置说明idForEachkey唯一主键第六节对比三种 keyemoji卡片顶部 34 号每话题一个情绪符号构成情绪光谱name# 名称15 号粗体与投稿页词表同名同序落库才能对上desc11 号灰字一句话定位maxLines(1)防溢出count11 号紫字热度本篇的主角private topics: Topic[] [ { id: 1, emoji: , name: 深夜emo, desc: 那些说不出口的情绪, count: 1289 }, { id: 2, emoji: , name: 学习焦虑, desc: 考试、论文、绩点, count: 986 }, { id: 3, emoji: , name: 人际关系, desc: 友情、爱情、室友, count: 756 }, { id: 4, emoji: , name: 青春疼痛, desc: 成长中的迷茫, count: 645 }, { id: 5, emoji: ☀️, name: 治愈瞬间, desc: 生活里的小确幸, count: 1102 }, { id: 6, emoji: , name: 未来规划, desc: 关于梦想和方向, count: 534 } ];两个值得注意的约定前 5 个话题与首页横滑带、投稿页词表完全同名同序深夜emo / 学习焦虑 / 人际关系 / 青春疼痛 / 治愈瞬间话题页多出的第 6 个「未来规划」是浏览入口的扩展。这套约定保证三处 UI 引用同一个命名空间——投稿时存的topic字符串在这里聚合、在首页过滤全部命中。词表若有偏差投稿就会掉进存在但永远不显示的暗数据。count不是等差数列1289 / 986 / 756 / 645 / 1102 / 534有头部有腰部方便验证第五节的千位格式化与排序效果。三、页面骨架与 Header 的固定高度算术build() { Column() { this.Header() Scroll() { Column({ space: 12 }) { this.TopicGrid() } .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外、Scroll用layoutWeight(1)吃满剩余高度、底部安全区公式D.pad this.safeBottom 20。三页共用同一套骨架沉浸式适配只需在 Ability 里做一次见 一首页 第四节。但本页 Header 的实现不同Builder Header() { Row() { Text(话题广场).fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) } .width(100%).height(this.safeTop 56).padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card).alignItems(VerticalAlign.Bottom) }三个数值配合出一条稳定基线height(this.safeTop 56)—— 总高 安全区 固定 56 的内容高。对比首页的双行 Header由内容自适应撑开锁定 56 的好处是不受字体度量差异影响切页时基线零抖动。padding({ top: this.safeTop })—— 内容从状态栏之下开始总高里已预留这段。alignItems(VerticalAlign.Bottom)—— 文本在 56 高的行内贴底。若居中未来一旦加副标题基线就会移动贴底则只往上生长。选型原则行数会变带副标题、搜索框用内容自适应高度行数固定就锁定高度。本页属于后者。四、TopicGridcolumnsTemplate的布局算术Builder TopicGrid() { Grid() { ForEach(this.topics, (t: Topic) { GridItem() { Column({ space: 8 }) { Text(t.emoji).fontSize(34) Text(# t.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Text(t.desc).fontSize(11).fontColor(C.textDim).maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(t.count 条树洞).fontSize(11).fontColor(C.primary) } .width(100%).padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) .onClick(() { promptAction.showToast({ message: t.name }); }) } }, (t: Topic) t.id.toString()) }.columnsTemplate(1fr 1fr).columnsGap(12).rowsGap(12).width(100%) }五个技术点逐个拆1fr 1fr是分数单位——fr即 fraction两列各占剩余宽度的 1/2。卡片宽度 (屏宽 − 左右 padding 32 − 列间距 12) ÷ 2全程不用写一个具体像素改三列只需把字符串改成1fr 1fr 1fr。GridItem内部必须width(100%)——GridItem自身尺寸等于 cell 尺寸但内部的Column不会自动撑满它。少了这一行卡片会缩成内容宽背景和描边全部露馅这是Grid的第一坑。maxLines三件套缺一不可——maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 撑满宽度。只有maxLines没有溢出策略文本会被硬裁剪而不是显示省略号没有撑满宽度就没有溢出可谈。columnsGap(12).rowsGap(12)—— 与首页卡片区space: 12、卡片内space: 8构成全应用三级间距系统12 卡片之间8 卡片内行间14/16 区块之间。卡片内四段式层级—— emoji(34) → 话题名(15 粗) → 描述(11 灰) → 热度(11 紫)字号与颜色双通道递减不用分隔线也能分出四个层级。演进响应式列数。手机两列、平板/折叠屏展开态三列import { display } from kit.ArkUI; private columnsTemplate(): string { const w: number px2vp(display.getDefaultDisplaySync().width); return w 600 ? 1fr 1fr 1fr : 1fr 1fr; } // TopicGrid 结尾改为 // .columnsTemplate(this.columnsTemplate()).columnsGap(12).rowsGap(12).width(100%)因为列宽是fr等分加一列不需要重算任何尺寸600vp 是折叠屏展开的常见宽度阈值。这是当初选columnsTemplate而不是写死卡片宽度的直接回报。五、热度不是字符串拼接格式化与排序第 61 行是本篇最有演进价值的一行Text(t.count 条树洞) // 1289 条树洞 1289 条树洞两个问题千位以上没有分隔读起来费劲热度永远乱序展示。两者都应发生在数据层 / 展示边界而不是 UI 结构里// common/Format.ets —— 展示层格式化 export function hotCount(n: number): string { if (n 10000) { return trimZero((n / 10000).toFixed(1)) w; // 12345 - 1.2w } if (n 1000) { return trimZero((n / 1000).toFixed(1)) k; // 1289 - 1.3k } return n.toString(); } function trimZero(s: string): string { return s.replace(/\.0$/, ); // 1.0 - 1 } // Func2Tab.ets —— 数据层排序 private reload(): void { const stats: TopicStat[] HoleStore.topicStats(); // map 已生成全新数组 this.topics stats.sort((a: TopicStat, b: TopicStat): number b.count - a.count); }两条纪律排序前先拿到新数组。topicStats()内部用map生成全新对象数组sort原地改动它不伤共享常量如果直接对模块级常量TOPIC_META.sort(...)下次进页面顺序就乱了——这是隐蔽性极强的状态污染。格式化只发生在渲染边界。count在数据层始终是number可比较、可排序、可累加只在Text(hotCount(t.count))的最后一刻变成1.3k 条树洞。一旦数据层存了格式化字符串所有聚合逻辑都要先反解析。六、三种 key 的选型本页为什么用id三个页面正好用过三种ForEachkey可以做一次对比页面key 函数数据源为什么首页横滑(t: string) t5 个话题名词表天然唯一且不重排投稿心情(m: string, idx: number) m idx5 个 emojiemoji 可能重复静态不重排场景拼索引兜底话题网格(t: Topic) t.id.toString()6 个完整对象有唯一主键最标准写法一句话总结有主键用主键没主键用唯一内容内容都可能重复时才动索引——且仅限数组静态不变的场景。一旦数组会增删或重排如本节加入排序后基于索引的 key 会导致节点错误复用ArkUI 按 key 匹配旧节点2对上了却换了位置界面与数据的对应关系就断了。七、从静态常量到聚合topicStats()的实时热度静态count的问题很明显投稿页发出一条「深夜emo」话题广场的 1289 纹丝不动。让它动起来需要把目录页接到HoleStore上// common/HoleStore.ets节选完整结构见首页篇第十节 export interface TopicStat { id: number; emoji: string; name: string; desc: string; count: number; } const TOPIC_META: TopicStat[] [ { id: 1, emoji: , name: 深夜emo, desc: 那些说不出口的情绪, count: 1289 }, { id: 2, emoji: , name: 学习焦虑, desc: 考试、论文、绩点, count: 986 }, { id: 3, emoji: , name: 人际关系, desc: 友情、爱情、室友, count: 756 }, { id: 4, emoji: , name: 青春疼痛, desc: 成长中的迷茫, count: 645 }, { id: 5, emoji: ☀️, name: 治愈瞬间, desc: 生活里的小确幸, count: 1102 }, { id: 6, emoji: , name: 未来规划, desc: 关于梦想和方向, count: 534 } ]; export class HoleStore { static topicStats(): TopicStat[] { return TOPIC_META.map((t: TopicStat): TopicStat { // 只统计本机新投稿线上基数 本地增量种子数据不重复计数 const added: number HoleStore.list.filter((h: Hole) h.topic t.name h.mine).length; return { id: t.id, emoji: t.emoji, name: t.name, desc: t.desc, count: t.count added }; }); } }话题页侧的改造StorageProp(holeVersion) version: number 0; // 订阅数据版本首页篇第十节 State topics: TopicStat[] []; aboutToAppear(): void { this.reload(); } private reload(): void { const stats: TopicStat[] HoleStore.topicStats(); this.topics stats.sort((a: TopicStat, b: TopicStat): number b.count - a.count); }从此形成闭环投稿页publish()→bump()版本号 1 → 话题页重拉 → 「深夜emo」的 1289 变 1290排序位次还可能上移。filter里带 h.mine的理由种子树洞本来就是「线上基数 1289」的一部分全部计入会双算只加mine才是我贡献的增量。八、点击话题之后路由参数与HoleCard复用Toast 之后应该是话题详情页。跳转与传参import { router } from kit.ArkUI; // TopicGrid 里替换 Toast .onClick(() { router.pushUrl({ url: pages/TopicDetail, params: { topic: t.name } }); })新页面TopicDetail.ets取参、按话题过滤然后复用首页篇抽出的HoleCard一首页 第九节// pages/TopicDetail.ets import { router } from kit.ArkUI; import { C, D } from ../common/Theme; import { Hole, HoleStore } from ../common/HoleStore; import { HoleCard } from ../components/HoleCard; Entry Component struct TopicDetail { StorageProp(safeTop) safeTop: number 0; StorageProp(safeBottom) safeBottom: number 0; State topic: string ; State holes: Hole[] []; aboutToAppear(): void { const p: Recordstring, string router.getParams() as Recordstring, string; if (p ! undefined p.topic ! undefined) { this.topic p.topic; this.holes HoleStore.byTopic(this.topic); // 首页篇第十节的 byTopic } } build() { Column() { Row({ space: 10 }) { Text(‹).fontSize(22).fontColor(C.text).onClick(() { router.back(); }) Text(# this.topic).fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text(this.holes.length 条).fontSize(12).fontColor(C.textDim) } .width(100%).height(this.safeTop 56) .padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card).alignItems(VerticalAlign.Bottom) Scroll() { Column({ space: 12 }) { if (this.holes.length 0) { Text(这个话题还没有树洞去投稿页种下第一条吧 ) .fontSize(13).fontColor(C.textDim).margin({ top: 120 }) } ForEach(this.holes, (h: Hole) { HoleCard({ hole: h, onHug: (id: number) { HoleStore.toggleHug(id); this.holes HoleStore.byTopic(this.topic); } }) }, (h: Hole) h.id.toString()) } .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) } }三个复用点值得强调HoleCard原样复用—— 首页渲染全部这里渲染过滤后的子集卡片组件一行没改。这是抽组件的直接回报。空态分支if (this.holes.length 0)—— 「未来规划」这类本地没有内容的话题进来不是白屏而是一句引导文案。空态是列表页最容易漏的分支。params只传话题名—— 不传整个Topic对象。路由参数应传键而不是值详情页拿键去HoleStore查最新数据而不是依赖跳转瞬间的快照这样别处更新后详情页也是对的。九、Grid 与 List 的选型维度GridcolumnsTemplateListGridContainer/Column换行列宽控制fr等分精确需手工算宽或用栅格容器间距控制columnsGap/rowsGap各自独立受space单一值约束懒加载支持按需加载支持适用话题/商品等目录型内容时间线等流式内容话题页选Grid的本质原因目录项是等权重的平行入口两列对称天然合理而首页的树洞是不等长的正文流单列Column ForEach才不会让长文挤压短文。目录用网格、正文用列表这个分界比哪个更新潮重要得多。十、实机验证1. 首屏两列三行六个话题格第一行# 深夜emo 1289 条树洞 / # 学习焦虑 986 条树洞GridItem内部Column撑满格子描边完整。2. 点治愈瞬间→ Toast「治愈瞬间」。闭包捕获各自的t六个格子参数正确。3. 点深夜emo→ Toast「深夜emo」。验证每格独立点击无串扰。4. 完整视图滚动到底最后一行☀️ 治愈瞬间 1102 / 未来规划 534完整可见底部安全区留白足够。验收清单项验证方式结果两列等分目测左右列宽完全相等格子撑满描边贴 cell 边缘正常width(100%)生效描述截断maxLines(1) Ellipsis未换行、未硬裁key 唯一t.id.toString()1~6 无冲突底部安全区滚到底最后一行未被导航条遮挡十一、可演进方向方向现状改法收益实时热度静态 1289topicStats()聚合mine增量投稿后数字立刻 1热度排序固定顺序sort按count降序热门话题置顶数字格式化count 条树洞hotCount()千位缩写1.3k 式紧凑展示响应式列数固定两列columnsTemplate()按 600vp 断点平板三列点击去向Toastrouter.pushUrl TopicDetail目录页真正可达空态无if (holes.length 0)引导文案冷启动不白屏十二、本篇小结话题页 70 行但它的三个决定都不在行数里1fr 1fr用分数单位让列宽免于像素计算、t.id.toString()让 key 在排序后依然正确、topicStats()让热度从写死数字变成map filter的实时聚合。目录用网格、正文用列表加上词表三页同名同序的约定这页才算真正接进了四页数据流。下一篇 四我的 讲统计端负 margin 悬浮卡的实现原理以及三个统计数字如何从静态字符串变成按mine聚合的真实值。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →