这一篇拆详情页。内容型 App 的质感往往不在列表页,而在详情页:图片要有沉浸感,关键信息要能扫读,收藏和加入行程要随手可点,返回路径要自然。

项目落点:

  • 详情状态:detailIddetailKind
  • 景点 / 美食详情:AtlasDetail()
  • 路线详情:RouteDetail()
  • 头图:DetailHero()
  • 操作栏:详情页底部 Row + Button
  • 信息区:InfoGrid()HighlightRow()FoodShopList()

读完这一篇,你可以复用一套详情页骨架:状态驱动进入详情,大图做顶部视觉,圆角内容面板承载正文,底部固定栏承载关键操作。

本文解决什么

  • detailId/detailKind 做轻量详情导航,不急着引入复杂路由。
  • 用大图头部、圆角内容面板和信息宫格,让攻略内容更容易阅读。
  • 用固定底部操作栏承载收藏和加入行程,同时处理内容避让和返回路径。

详情页决定内容 App 的质感

首页负责吸引,探索页负责查找,详情页负责让用户真正消费内容。

《疆域纪行》里有两类详情页:

  • 景点 / 美食详情:AtlasDetail
  • 路线详情:RouteDetail

它们共享同一种视觉结构:

  1. 顶部返回栏
  2. 大图头图
  3. 圆角内容面板
  4. 分段信息
  5. 固定底部操作栏

这种结构很适合旅行、资讯、图鉴、课程等内容型 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)。图片比例不同也不会撑乱页面。

第二,长文本设置 lineHeightmaxLinestextOverflow。旅行内容的标题和摘要经常很长。

第三,底部操作栏固定时,滚动内容要留出底部 padding。否则最后一段内容会被按钮遮住。

这些都不是炫技,却是决定页面是否像成品的细节。

验证清单

检查项 操作方式 通过标准
从首页、探索、收藏进入详情都能正常显示 对照项目文件或真机/模拟器操作 结果符合文章描述
系统返回键会关闭详情而不是直接退出 App 对照项目文件或真机/模拟器操作 结果符合文章描述
底部按钮不会遮住最后一段内容 对照项目文件或真机/模拟器操作 结果符合文章描述
景点和美食展示不同内容块 对照项目文件或真机/模拟器操作 结果符合文章描述
收藏状态在详情头部和底部按钮同步 对照项目文件或真机/模拟器操作 结果符合文章描述

常见问题和处理

问题现象 优先排查 处理方式
详情页打开错误内容 检查 detailKinddetailId 是否一起设置 按类型走不同查找函数
底部按钮遮挡正文 检查详情内容 bottom padding 给固定操作栏留出滚动空间
返回后 Tab 丢失 不要切换主 Tab 状态 只清空详情状态

本篇小结

详情页实战可以总结为:

  • detailId/detailKind 做轻量详情导航。
  • 用 fallback 防止旧 id 或异常 id 导致崩溃。
  • 用大图 + 圆角内容面板形成沉浸式阅读。
  • 用固定底部栏承载收藏和行程操作。
  • 用信息宫格提升攻略类内容的可扫读性。
  • kind 分支处理景点和美食的差异。

下一篇我们继续看路线模块:如何把多日旅行路线做成时间轴,并和本地行程清单打通。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐