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分支处理景点和美食的差异。下一篇我们继续看路线模块如何把多日旅行路线做成时间轴并和本地行程清单打通。

相关新闻