HarmonyOS实战《疆域纪行》第06篇|沉浸式详情页:大图头部、信息宫格和固定操作栏
这一篇拆详情页。内容型 App 的质感往往不在列表页,而在详情页:图片要有沉浸感,关键信息要能扫读,收藏和加入行程要随手可点,返回路径要自然。
项目落点:
- 详情状态:
detailId、detailKind - 景点 / 美食详情:
AtlasDetail() - 路线详情:
RouteDetail() - 头图:
DetailHero() - 操作栏:详情页底部
Row + Button - 信息区:
InfoGrid()、HighlightRow()、FoodShopList()
读完这一篇,你可以复用一套详情页骨架:状态驱动进入详情,大图做顶部视觉,圆角内容面板承载正文,底部固定栏承载关键操作。
本文解决什么
- 用
detailId/detailKind做轻量详情导航,不急着引入复杂路由。 - 用大图头部、圆角内容面板和信息宫格,让攻略内容更容易阅读。
- 用固定底部操作栏承载收藏和加入行程,同时处理内容避让和返回路径。
详情页决定内容 App 的质感
首页负责吸引,探索页负责查找,详情页负责让用户真正消费内容。
《疆域纪行》里有两类详情页:
- 景点 / 美食详情:
AtlasDetail - 路线详情:
RouteDetail
它们共享同一种视觉结构:
- 顶部返回栏
- 大图头图
- 圆角内容面板
- 分段信息
- 固定底部操作栏
这种结构很适合旅行、资讯、图鉴、课程等内容型 App。
用状态进入详情页
项目没有使用复杂路由,而是用两个状态控制详情页:
@State detailId: string = '';
@State detailKind: string = '';
打开详情:
private openDetail(kind: string, id: string): void {
this.detailKind = kind;
this.detailId = id;
}
关闭详情:
private closeDetail(): void {
this.detailId = '';
this.detailKind = '';
}
主 build() 根据这两个状态决定显示什么:
if (this.detailId.length > 0 && this.detailKind === 'route') {
this.RouteDetail(this.findRouteItem(this.detailId));
} else if (this.detailId.length > 0) {
this.AtlasDetail(this.findAtlasItem(this.detailId));
} else {
this.MainShell();
}
这套方式对单页壳应用很友好。它让详情页像“覆盖在主页面上”的层级,返回时也能回到原 Tab 状态。
兜底查找,避免详情页崩溃
详情页查找内容时有兜底:
private findAtlasItem(id: string): AtlasItem {
const fallback: AtlasItem = this.spots[0];
const found: AtlasItem | undefined = this.getAllItems().find((item: AtlasItem) => item.id === id);
return found ? found : fallback;
}
private findRouteItem(id: string): RouteItem {
const fallback: RouteItem = this.routes[0];
const found: RouteItem | undefined = this.routes.find((item: RouteItem) => item.id === id);
return found ? found : fallback;
}
这很适合离线内容 App。因为收藏、行程里保存的是 id,如果未来内容库调整,旧 id 找不到时,页面不应该直接崩溃。
更进一步的优化是显示一个“内容已更新或不存在”的空状态。但 MVP 阶段使用 fallback 可以保证稳定性。
景点 / 美食详情页结构
AtlasDetail 的骨架如下:
@Builder
AtlasDetail(item: AtlasItem) {
Stack({ alignContent: Alignment.TopStart }) {
Column() {
this.DetailTopBar()
Scroll() {
Column({ space: 0 }) {
this.DetailHero(item.color, item.image)
Column({ space: 16 }) {
this.AtlasDetailHeader(item)
Text(item.summary)
.fontSize(14)
.fontColor('#34424B')
.lineHeight(23)
this.InfoGrid(item)
if (item.kind === '美食') {
this.SectionTitle('推荐店铺', 'RECOMMENDED PLACES')
this.FoodShopList(item)
} else {
this.SectionTitle('游玩亮点', 'HIGHLIGHTS')
this.HighlightRow(item)
}
this.SectionTitle('注意事项', 'NOTES')
ForEach(item.tips, (tip: string) => {
this.NoteRow(tip)
}, (tip: string) => tip)
}
.padding({ left: 18, right: 18, top: 18, bottom: 188 })
.backgroundColor('#F6F1E8')
.borderRadius({ topLeft: 28, topRight: 28 })
.margin({ top: -34 })
}
}
.layoutWeight(1)
Row({ space: 12 }) {
Button(this.isFavorite(item.id) ? '已收藏' : '加入收藏')
.layoutWeight(1)
.height(46)
.backgroundColor('#B9823E')
.fontColor(Color.White)
}
.padding({ left: 18, right: 18, top: 12, bottom: 18 })
.backgroundColor('#FFFFFC')
}
}
}
这里最关键的是内容面板:
.borderRadius({ topLeft: 28, topRight: 28 })
.margin({ top: -34 })
头图在上,内容面板向上压一点,形成沉浸式衔接。这种手法很常见,但要注意底部 padding。因为底部还有固定操作栏,内容区必须留出足够空间。
项目使用:
.padding({ left: 18, right: 18, top: 18, bottom: 188 })
这样滚动到最后时,免责声明和注意事项不会被底部按钮盖住。
固定底部操作栏
详情页底部按钮固定在屏幕下方:
Row({ space: 12 }) {
if (item.kind === '景点') {
Button(this.isInItinerary(item.id) ? '已加入行程' : '加入行程')
.layoutWeight(1)
.height(46)
.backgroundColor(this.isInItinerary(item.id) ? '#172A3A' : '#FFFFFF')
.fontColor(this.isInItinerary(item.id) ? Color.White : '#172A3A')
.onClick(() => {
this.toggleItinerary(item.id);
})
} else {
Button('查看美食列表')
.layoutWeight(1)
.height(46)
.backgroundColor('#FFFFFF')
.fontColor('#172A3A')
.onClick(() => {
this.exploreType = 'food';
this.activeTab = 'explore';
this.closeDetail();
})
}
Button(this.isFavorite(item.id) ? '已收藏' : '加入收藏')
.layoutWeight(1)
.height(46)
.backgroundColor('#B9823E')
.fontColor(Color.White)
.onClick(() => {
this.toggleFavorite(item.id);
})
}
.padding({ left: 18, right: 18, top: 12, bottom: 18 })
.backgroundColor('#FFFFFC')
.border({ width: { top: 1 }, color: '#E8DED1' })
这个操作栏有两个产品细节。
第一,景点显示“加入行程”,美食显示“查看美食列表”。同一个详情结构,根据内容类型给不同主操作。
第二,收藏按钮状态实时变化。用户点击后从“加入收藏”变成“已收藏”,这是本地状态驱动 UI 的直接体现。
信息宫格:把攻略字段变得可扫读
详情页不是把所有文字堆成一篇长文,而是把关键信息拆成宫格:
@Builder
InfoGrid(item: AtlasItem) {
Column({ space: 10 }) {
Row({ space: 10 }) {
this.InfoCell('季节', item.bestSeason)
this.InfoCell('时长', item.duration)
}
Row({ space: 10 }) {
this.InfoCell('费用', item.cost)
this.InfoCell('交通', item.transport)
}
}
}
旅行攻略的核心信息通常是“什么时候去、玩多久、怎么去、花费如何”。把这些字段做成卡片,比藏在正文里更容易扫读。
内容分型:景点展示亮点,美食展示店铺
景点和美食共用 AtlasDetail,但中间内容不同:
if (item.kind === '美食') {
this.SectionTitle('推荐店铺', 'RECOMMENDED PLACES')
this.FoodShopList(item)
} else {
this.SectionTitle('游玩亮点', 'HIGHLIGHTS')
this.HighlightRow(item)
}
这就是共用模型的收益:大结构一致,小差异通过 kind 分支解决。
美食数据里有可选字段:
shops?: string[];
渲染时做空数组兜底:
ForEach(item.shops ? item.shops : [], (shop: string) => {
Row({ space: 10 }) {
Text('店')
.fontSize(12)
.fontColor(Color.White)
.width(30)
.height(30)
.textAlign(TextAlign.Center)
.backgroundColor('#B9823E')
.borderRadius(15)
Text(shop)
.fontSize(14)
.fontColor('#26333B')
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
})
可选字段一定要小心处理。不要假设每条数据都有完整字段,否则内容库稍微缺一项,页面就会不稳定。
返回栏和系统返回键都要处理
顶部返回栏:
@Builder
DetailTopBar() {
Row() {
Text('返回')
.onClick(() => {
this.closeDetail();
})
}
}
系统返回键:
onBackPress(): boolean {
if (this.detailId.length > 0) {
this.closeDetail();
return true;
}
return false;
}
两个入口都要有。移动端用户有的人点页面返回,有的人按系统返回。只支持一种会让体验不完整。
详情页的三个稳定性细节
第一,头图使用固定高度和 objectFit(ImageFit.Cover)。图片比例不同也不会撑乱页面。
第二,长文本设置 lineHeight、maxLines 和 textOverflow。旅行内容的标题和摘要经常很长。
第三,底部操作栏固定时,滚动内容要留出底部 padding。否则最后一段内容会被按钮遮住。
这些都不是炫技,却是决定页面是否像成品的细节。
验证清单
| 检查项 | 操作方式 | 通过标准 |
|---|---|---|
| 从首页、探索、收藏进入详情都能正常显示 | 对照项目文件或真机/模拟器操作 | 结果符合文章描述 |
| 系统返回键会关闭详情而不是直接退出 App | 对照项目文件或真机/模拟器操作 | 结果符合文章描述 |
| 底部按钮不会遮住最后一段内容 | 对照项目文件或真机/模拟器操作 | 结果符合文章描述 |
| 景点和美食展示不同内容块 | 对照项目文件或真机/模拟器操作 | 结果符合文章描述 |
| 收藏状态在详情头部和底部按钮同步 | 对照项目文件或真机/模拟器操作 | 结果符合文章描述 |
常见问题和处理
| 问题现象 | 优先排查 | 处理方式 |
|---|---|---|
| 详情页打开错误内容 | 检查 detailKind 和 detailId 是否一起设置 |
按类型走不同查找函数 |
| 底部按钮遮挡正文 | 检查详情内容 bottom padding | 给固定操作栏留出滚动空间 |
| 返回后 Tab 丢失 | 不要切换主 Tab 状态 | 只清空详情状态 |
本篇小结
详情页实战可以总结为:
- 用
detailId/detailKind做轻量详情导航。 - 用 fallback 防止旧 id 或异常 id 导致崩溃。
- 用大图 + 圆角内容面板形成沉浸式阅读。
- 用固定底部栏承载收藏和行程操作。
- 用信息宫格提升攻略类内容的可扫读性。
- 用
kind分支处理景点和美食的差异。
下一篇我们继续看路线模块:如何把多日旅行路线做成时间轴,并和本地行程清单打通。
更多推荐


所有评论(0)