〖共创稿事节〗HarmonyOS 7 新特性实战(58):首页知识卡折叠与展开——九宫格底图、双卡拆分与一次真实的展开
# 〖共创稿事节〗HarmonyOS 7 新特性实战(58):首页知识卡折叠与展开——九宫格底图、双卡拆分与一次真实的展开
「山海万灵」的首页原先只有三张进度卡:已发现多少只异兽、护照盖了多少印章、读完了多少篇章。这些是「读者向」的数字,但整个应用收录了多少东西——20 篇古籍、912 段原文、7.7 万字、36 只异兽、90 条关联——没有地方讲。直接再加一张全宽的「已收录」卡,首屏就塞满了:这些深度信息大部分人根本不看。
最后的形态是一张默认收起的手风琴卡:收起时只有一行——「已收录」三个小字加一句金色总账;点一下展开,露出形态分布、四个入口(典籍 / 异兽 / 地域 / 读解)和各层统计。进度卡留在首屏,体量账折叠在下面。这个决策后来用门禁钉死了:三张进度卡必须在「已收录」之前,且后者默认 expanded: false。
真正值得写的是它后来的三次翻车:底图被拉糊、展开挤压下方内容、以及那个最隐蔽的——展开层做成了浮层,结果整块点不动。
一、信息分层:谁进首屏,谁折叠
| 层 | 内容 | 位置 | 状态 |
|---|---|---|---|
| 读者进度 | 已发现 / 印章 / 已读篇章 | 首屏三张卡 | 常驻 |
| 收录体量 | 篇 / 节 / 段 / 注 / 字数 / 形态分布 | 下方手风琴卡 | 默认收起 |
| 各层入口 | 典籍 / 异兽 / 地域 / 读解 | 展开后露出 | 点击进栈 |
折叠态只留两个元素:小标题「已收录」(次要色)和总账数字(金色),高度 46vp。展开后的内容是另一张卡:形态分布色条、图例、四行入口。已读计数来自持久化的篇章 ID 串,读完第一章后首页立刻变成 1/20——这个联动在折叠与展开两种状态下都成立,切深色再切回来,展开态和计数都还在,页面没有重建。
还有一条硬约束:卡里所有数字必须来自数据汇总层,不许手写。门禁脚本独立遍历原始数据再与卡片拿到的总账逐项比对,连「卡片源码里不许出现两位数以上的字面量」都断言了——一旦有人为了好看把「912 段」写成常量,内容一改首页就开始撒谎,而且没人会发现。
二、第一次翻车:一张图伺候不了两种高度
收起 46vp、展开约 330vp,同一张卡的高度在 7 倍之间变化。底图最早是纯色横幅,夹在三张纸纹进度卡中间突兀得像一块补丁,于是换成与「今日路线」同源的宽卡底图(3.86:1)。问题跟着来了——同一张图怎么铺到两种高度上,四种方案各有死法:
| 方案 | 收起态 46vp | 展开态 330vp | 结论 |
|---|---|---|---|
Stack 叠 Image(height 100%) | 自适应高度下没有确定基准,直接塌陷 | 同样塌陷 | 排除 |
backgroundImageSize(Cover) | 按宽度放大后纵向裁掉 35px,四角云纹和金边被裁光 | 看不出来 | 排除 |
backgroundImageSize(Fill) | 四角云纹纵向压扁三倍 | 中间被拉稀 | 排除 |
backgroundImageResizable 九宫格 | 四角原比例,只拉伸中间纸纹 | 同左 | 采用 |
九宫格的 slice 是 top/bottom 16、left/right 48,正好包住原图约 150×110px 的云纹。这是 backgroundImageResizable 的标准用法(官方背景属性文档):slice 圈出的四个角原样绘制,四条边沿一个方向拉伸,中间区域双向拉伸——所以拉伸只发生在没有细节的纸纹上。

头部最终长这样:
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('已收录')
.fontSize(ShanhaiTypography.fp(10))
.fontColor(this.sub())
Text(this.ledger.summary)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(this.gold())
}
.layoutWeight(1)
Image(this.isDarkMode ? $r('app.media.archive_icon_chevron_down_dark')
: $r('app.media.archive_icon_chevron_down_light'))
.width(14)
.height(14)
.rotate({ angle: this.expanded ? 180 : 0 })
}
.width('100%')
// 高度不写死:按底图 3.86:1 跟着宽度走,宽屏分栏左栏 624vp 时
// 高度自动长到约 162vp,底图不再被横向拉成 13.6:1
.aspectRatio(3.86)
.backgroundImage(this.wideCardAsset(), ImageRepeat.NoRepeat)
.backgroundImageSize(ImageSize.FILL)
.backgroundImageResizable({ slice: { top: 16, right: 48, bottom: 16, left: 48 } })
.borderRadius(9)
.clip(true)
.onClick(() => {
this.expanded = !this.expanded;
})
两个细节值得展开。其一,.aspectRatio(3.86) 是被平板逼出来的:分栏态左栏 720vp 时卡全宽 624vp,高度还固定 46vp 的话宽高比变成 13.6:1,底图横向拉伸 3.5 倍。改按底图比例跟随宽度后,实测 Header 宽 1360px / 高 352px——恰好 3.86,零拉伸。其二,borderRadius(9) 不是拍脑袋:宽卡原图圆角 30.7px(1167px 宽),折到 340vp 宽的卡上约 8.8vp,之前写死的 16 比它大一倍,看着像被削掉一圈;深色底图自带的角又只有约 2vp,接近直角,统一写 9 略大于底图角,只会裁掉不透明的纸角,不会露出透明缝。
三、第二次翻车:展开态把纸纹拉糊了
九宫格救了收起态,救不了展开态。头部 46vp、展开内容约 250vp,同一张 3.86:1 的图要伺候两种比例,九宫格只能靠把中间纸纹纵向拉伸来补——收起态还行,展开态纸纹被拉长 5 倍以上,纹理整个糊开。真机截图交给评审,反馈就一句:下面用另一张卡底图区分开。
拆成两张独立卡,中间留 8vp:
- 头部:宽卡底图(3.86:1),高度由
aspectRatio跟着宽度走; - 展开内容:面板卡底图(2.5:1,和「AI 馆长建议」同源),比例本来就贴近展开后的方块,四角云纹大(原图圆角约 112px),slice 给到 32。
// 展开内容:独立的第二张卡(面板卡底图),和头部之间留 8vp
Column({ space: 10 }) {
Text(this.ledger.subSummary) // 7.7 万字 · 36 只 · 90 条关联…
.fontSize(ShanhaiTypography.fp(11))
.fontColor(this.muted())
this.ShapeBar() // 形态分布色条
this.ShapeLegend() // 图例:色块 + 名称 数量
Column({ space: 6 }) {
ForEach(this.ledger.layers, (layer: ShanhaiKnowledgeLayer) => {
this.LayerRow(layer) // 典籍 / 异兽 / 地域 / 读解
}, (layer: ShanhaiKnowledgeLayer) => 'layer-' + layer.key)
}
.width('100%')
}
.width('100%')
.padding({ left: 16, right: 16, top: 20, bottom: 16 })
.backgroundImage(this.panelCardAsset(), ImageRepeat.NoRepeat)
.backgroundImageSize(ImageSize.FILL)
.backgroundImageResizable({ slice: { top: 32, right: 32, bottom: 32, left: 32 } })
.clip(true)
内边距跟着面板卡的大圆角放大到 top 20,否则首行「7.7 万字…」会被顶部圆弧切到;头部左右内边距同步改成 16,两张卡的正文左边缘才对得齐。形态分布色条有个小设计:每块色条 layoutWeight(shape.count),透明度按数量归一——最多的 1.00,最少的 0.30。读者不用看数字也能看出「异兽最多」,但只有一行同色色块时编码是白给的,所以图例里每项都带一块同透明度的小方块:
// 数量越多色块越实:最少的 0.30,最多的 1.00
private shapeOpacity(count: number): number {
const ratio: number = count / this.maxShapeCount();
return Math.round((0.3 + 0.7 * ratio) * 100) / 100;
}

文字色也照着顶部三张进度卡抄了一遍层次,改前改后:
| 位置 | 改前 | 改后 |
|---|---|---|
| 「已收录」小标题 | muted | textSecondary |
| 总账数字 | textPrimary | gold |
| 各层小标题 | gold | textSecondary |
| 各层数量 | textPrimary | gold |
| 注释行 | textSecondary | muted |
规则很简单:小标题=次要色、数字=金色、注释=弱化色。同一屏里同一层级的字必须一个颜色,门禁里连「总账数字必须用 gold」都写了正则断言。
四、第三次翻车:浮层是画出来了,点不动了
到这一步卡是好看了,但展开会把下面的地图、今日路线整体挤下去——用户不想要这个位移。当时的解法是浮层:外层 Column 用负的下外边距把它自己撑出来的高度抵消掉,布局占位永远是收起态的 46vp,溢出部分靠 zIndex 盖在后面的兄弟节点之上:
// 已废弃的浮层方案:布局占位恒为 46vp,展开内容浮在地图之上
.margin({ bottom: this.expanded ? -(this.detailsHeight + 8) : 0 })
.zIndex(this.expanded ? 10 : 0)
detailsHeight 不能写死——图例是 FlexWrap,6 项在窄屏可能换行成两行,所以由 Details 的 onAreaChange 实测后写进状态。效果验证过:冷启后同一位置 dumpLayout 两次,「今日路线」文字 y 坐标收起 2380px、展开 2380px,位移 0,展开内容浮在地图之上,到今日路线卡上方就结束。手机上看起来完美。
坏在平板上。应用改自绘分栏之后,分栏态点「典籍 / 异兽」,右栏毫无反应——LayerRow 明明 clickable=true,pushPath 从未发生。父链 dumpLayout 实测给出了决定性数字:
| 父容器高 | 卡片高 | 结果 | |
|---|---|---|---|
| 浮层方案 | 260px(130vp,收起态) | 731px | 溢出 471px,超出部分点不动 |
| 正常占位 | 899px | 731px | 完整包住,可点 |
ArkUI 不命中超出父容器 bounds 的触摸。 负外边距把父容器高度压回收起态,Details 渲染在父容器 bounds 之外——画是画出来了(绘制不受裁剪),但触摸命中做不了。手机上一直没发现,是因为首页里展开层下面那几行恰好用不上?不,是因为手机上从来没人点过展开层里的入口——四个入口在手机上也能进详情,只是验收时点的都是头部。
修法是整个放弃浮层,改回正常占位:展开时下方内容下移,换整块可点击。门禁同步做了件少见的事——把原来「必须负外边距」的断言原地反转为「禁止负外边距」,连 detailsHeight 和 onAreaChange 一起禁掉,防止哪天又有人觉得「位移 0」很香把它加回来:
// 门禁里的三条反转断言(verify-home-knowledge-ledger.mjs)
assert.doesNotMatch(stripComments(card),
/\.margin\(\{[\s]*bottom:[\s]*this\.expanded[\s]*\?[\s]*-\(/,
'展开层不能再用负外边距:父容器高度被压回收起态,Details 超出 bounds 的部分不响应触摸');
assert.doesNotMatch(stripComments(card), /@State private detailsHeight/,
'展开层正常占位后不再需要实测高度做抵消');
assert.match(card, /@State private expanded: boolean = false/);
修复后实测链路:点「典籍」→ 右栏出现 NavDestination,渲染完整的古籍目录 26 条文本;点「异兽」「地域」「读解」同样成立。首页侧的连带改动只有一处:宽屏并排时三张进度卡与这张卡共享一行,展开会把行撑高,alignItems 由 Center 改 Top,三卡顶对齐。

五、门禁:这张卡的全部价值在于数字是真的
上面每一节对应一组断言,全在 verify-home-knowledge-ledger.mjs 里,当前全量 43 个门禁 PASS。它管四类事:
| 类别 | 断言(节选) | 防的是什么 |
|---|---|---|
| 数字真源 | 篇/节/段/注/字数独立遍历比对;卡片源码禁止两位数以上字面量 | 有人把「912 段」写成常量 |
| 层级与手风琴 | 进度卡必须排在前面;expanded 默认 false;点头部切换 | 深度信息顶到首屏 |
| 底图 | 必须九宫格;禁止退回纯色横幅;展开内容必须用面板卡底图;禁止 10~99 的手写圆角 | Cover 裁光云纹、Fill 压扁、纸纹拉糊 |
| 浮层禁令 | 禁止负外边距 + zIndex;禁止 detailsHeight | 超出 bounds 点不动 |
这个模式在项目里反复被验证:凡是「评审时看着对」的东西,都要在门禁里留一条会红的断言。弧角写死 16 是看着对,九宫格参数写错是看着对,浮层位移 0 更是连实测都对——直到有人用手指去点。断言不保证设计对,只保证设计不被悄悄改掉。

收个尾。这张卡最终交付的形态:收起一行总账(46vp,比例精确 3.86),展开一张面板卡——形态色条与图例、四层入口、各层统计;浅深两态各有两张专用底图;宽屏与三张进度卡并排时固定 262×68 维持底图比例;展开正常占位,下方内容随之下移。代价是展开时那一下位移,换来的是展开层里每一个像素都可点。两版方案都实测过:位移 0 的版本溢出 471px 点不动,位移存在的那版 899px 完整包住。手风琴不是把内容藏起来,是决定哪些内容值得一次点击——以及确保点下去有反应。
附:本篇证据清单
- 验收记录:折叠/展开浅深两态、读完一章 1/20、我的页与印章墙前后对照(
acceptance/2026-10-21-home-knowledge-accordion/,6 张截图);底图三轮改造与浮层位移实测(acceptance/2026-10-22-home-card-asset-dark-tab/);平板浮层点不动修复(acceptance/2026-10-26-parallel-view-draggable/,evidence/tablet-fix-catalog.json)。 - 源码:
ShanhaiKnowledgeStructureCard.ets(手风琴卡全量 261 行)、verify-home-knowledge-ledger.mjs(229 行门禁)。 - 实测数字:门禁输出「20 篇 / 58 节 / 912 段 / 1762 条旧注 / 76,859 字 / 36 只 / 90 条关联 / 9 域 8 厅 / 72 张读解卡」;浮层修复前后父容器 260px → 899px;宽屏 Header 1360×352px(比例 3.86)。
- 尚未覆盖:展开动效参数与低端机表现没有单独验过,动效目前走的是 ArkUI 默认布局动画,这条留给下次真机回归。
更多推荐
所有评论(0)