鸿蒙原生应用实战(二):首页开发 —— Grid分类网格与热歌排行榜

前言

上一篇完成了项目初始化与架构设计。本篇进入核心开发——首页(Index.ets)

首页是App的门面,「乐迷笔记」的首页承载着五大模块:

  1. 顶部问候栏 —— 根据时段显示不同问候语
  2. 每日精选Banner —— 促销/推荐展示位
  3. 音乐分类网格 —— 8大分类的2×4 Grid布局
  4. 推荐专辑横向滚动 —— 向左滑动发现更多
  5. 热歌排行榜 —— 带序号的Top8榜单

这些功能模块覆盖了 ArkTS 中最常用的布局组件:Grid、Scroll、Row、Column、List、Stack 等。


一、页面结构规划

Index.ets
├── @Builder buildTopBar()          ← 问候语 + 头像按钮
├── @Builder buildDateRow()         ← 日期显示
├── @Builder buildBanner()          ← 每日精选广告位
├── @Builder buildGenreGrid()       ← 8大音乐分类 (Grid 2×4)
├── @Builder buildFeaturedAlbums()  ← 推荐专辑 (Scroll 横向)
├── @Builder buildTopChart()        ← 热歌榜 Top8
├── @Builder buildBottomNav()       ← 底部导航 (首页/发现/歌单/我的)
└── build()                         ← 主布局组装

1.1 数据接口

interface Genre {
  name: string;    // 分类名:流行/摇滚/民谣...
  icon: string;    // Emoji图标
  color: string;   // 展示色
  count: number;   // 歌曲数量
}

interface Album {
  id: number;       // 专辑ID(路由传参用)
  title: string;    // 专辑名
  artist: string;   // 歌手
  cover: string;    // 封面(预留)
  year: number;     // 发行年份
  genre: string;    // 类型
  songCount: number;
  rating: number;   // 评分(0-50整数)
}

interface Song {
  id: number;
  title: string;
  artist: string;
  albumId: number;  // 关联专辑ID
  duration: string; // 时长 mm:ss
  plays: number;    // 播放量
}

二、@State状态变量与初始化

2.1 状态声明

@Component
struct Index {
  @State currentDate: string = '';
  @State greeting: string = '';
  @State genres: Genre[] = [];
  @State featuredAlbums: Album[] = [];
  @State topSongs: Song[] = [];
}

全部使用 @State 装饰,任何修改都会自动触发UI刷新。

2.2 日期与问候语

initDateAndGreeting(): void {
  const now: Date = new Date();
  const hour: number = now.getHours();

  // 根据不同时段返回不同问候语
  if (hour < 6) this.greeting = '夜深了';
  else if (hour < 9) this.greeting = '早上好';
  else if (hour < 12) this.greeting = '上午好';
  else if (hour < 14) this.greeting = '中午好';
  else if (hour < 18) this.greeting = '下午好';
  else if (hour < 22) this.greeting = '晚上好';
  else this.greeting = '夜深了';

  // 格式化日期
  const year: number = now.getFullYear();
  const month: number = now.getMonth() + 1;
  const day: number = now.getDate();
  const weekMap: string[] = ['日', '一', '二', '三', '四', '五', '六'];
  this.currentDate = `${year}${month}${day}日 星期${weekMap[now.getDay()]}`;
}

设计细节:21:00-06:00 显示"夜深了",暗示用户该休息了——从细节上体现产品温度。

2.3 八大分类数据

initGenres(): void {
  this.genres = [
    { name: '流行', icon: '🎤', color: '#FF6B6B', count: 128 },
    { name: '摇滚', icon: '🎸', color: '#4ECDC4', count: 96 },
    { name: '民谣', icon: '🎶', color: '#45B7D1', count: 64 },
    { name: '电子', icon: '🎧', color: '#96CEB4', count: 72 },
    { name: '古典', icon: '🎻', color: '#DDA0DD', count: 48 },
    { name: 'R&B', icon: '🎵', color: '#F0E68C', count: 56 },
    { name: '嘻哈', icon: '🎙️', color: '#FFA07A', count: 80 },
    { name: '爵士', icon: '🎷', color: '#87CEEB', count: 40 }
  ];
}

每个分类包含:名称、Emoji图标、品牌色、歌曲数量。这8个数据涵盖了主流音乐类型,足够展示分类网格的多样性。


三、@Builder组件详解

3.1 顶部问候栏 buildTopBar()

@Builder buildTopBar() {
  Row() {
    Column() {
      Text(this.greeting)
        .fontSize(14).fontColor('#7C3AED').fontWeight(FontWeight.Medium)
      Text('探索音乐的无限可能')
        .fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Start)

    Blank()

    // 头像按钮 → 跳转个人中心
    Stack() {
      Column().width(40).height(40).borderRadius(20)
        .backgroundColor('#EDE9FE')
      Text('🎵').fontSize(20)
    }
    .onClick(() => { router.pushUrl({ url: 'pages/ProfilePage' }); })
  }
  .width('100%').padding({ left: 20, right: 20, top: 16 })
}

布局要点

  • 左侧:问候语(动态) + 副标题(固定)
  • 右侧:带Emoji的圆形头像按钮
  • Blank() 自动撑满中间空间,实现左右对齐

3.2 Banner buildBanner()

@Builder buildBanner() {
  Stack() {
    Column().width('100%').height(140)
      .backgroundColor('#EDE9FE').borderRadius(16)

    Row() {
      Column() {
        Text('🎶 每日精选').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#5B21B6')
        Text('发现属于你的旋律').fontSize(12).fontColor('#7C3AED').margin({ top: 6 })
        Text('今日更新 20+ 首新歌').fontSize(11).fontColor('#A78BFA').margin({ top: 4 })
      }.margin({ left: 20 })

      Blank()

      Text('🎵').fontSize(56).margin({ right: 20 })
    }
    .width('100%')
  }
  .width('100%').height(140).margin({ top: 12 })
  .padding({ left: 20, right: 20 })
}

Stack + Row 双布局实现:底层是紫色背景,上层是Row(左侧文案 + 右侧大号Emoji),文字与装饰元素叠放。这种设计是Banner广告位的标准实现方式。

3.3 音乐分类 Grid buildGenreGrid()

这是首页最核心的组件之一,使用 Grid 容器实现 4列×2行 的排列:

@Builder buildGenreGrid() {
  Column() {
    // 区域标题
    Row() {
      Text('音乐分类').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
      Blank()
      Text('全部 >').fontSize(12).fontColor('#7C3AED')
    }
    .width('100%').padding({ left: 20, right: 20, top: 20 })

    // Grid网格
    Grid() {
      ForEach(this.genres, (genre: Genre) => {
        GridItem() {
          Column() {
            Text(genre.icon).fontSize(28)
            Text(genre.name).fontSize(12).fontColor('#1F1B2E')
              .fontWeight(FontWeight.Medium).margin({ top: 6 })
            Text(`${genre.count}`).fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
          }
          .width('100%')
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 16, bottom: 12 })
          .backgroundColor('#FFFFFF').borderRadius(12)
        }
        .onClick(() => {
          router.pushUrl({
            url: 'pages/SearchPage',
            params: { genre: genre.name }  // 跳转发现页并携带分类参数
          });
        })
      }, (genre: Genre) => genre.name)
    }
    .columnsTemplate('1fr 1fr 1fr 1fr')   // 4列等宽
    .rowsTemplate('1fr 1fr')              // 2行
    .columnsGap(12)                        // 列间距
    .rowsGap(12)                           // 行间距
    .width('100%')
    .padding({ left: 20, right: 20, top: 12 })
  }
}

Grid核心属性

属性 说明
columnsTemplate '1fr 1fr 1fr 1fr' 4列,每列等分剩余空间
rowsTemplate '1fr 1fr' 2行,高度相等
columnsGap 12 列间距12vp
rowsGap 12 行间距12vp

fr单位:类似CSS的flex-grow,1fr 表示等分一份。4个 1fr = 四等分。

Grid vs ForEach → Column/Row

  • Grid:真正支持行列对齐,适合日历、分类等规则排列
  • 手动Column/Row:适合不规则布局

点击跳转带参:每个GridItem点击时,携带 genre 参数跳转到搜索页,搜索页自动选中对应分类。

3.4 推荐专辑横向滚动 buildFeaturedAlbums()

@Builder buildFeaturedAlbums() {
  Column() {
    Row() {
      Text('推荐专辑').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
      Blank()
      Text('更多 >').fontSize(12).fontColor('#7C3AED')
    }
    .width('100%').padding({ left: 20, right: 20, top: 20 })

    Scroll() {                     // ← 外层Scroll控制水平滚动
      Row() {                      // ← Row容纳多个专辑卡片
        ForEach(this.featuredAlbums, (album: Album) => {
          Column() {
            // 封面占位
            Stack() {
              Column().width(130).height(130)
                .backgroundColor('#EDE9FE').borderRadius(12)
              Text('💿').fontSize(44)
            }
            .width(130).height(130)

            Text(album.title).fontSize(13).fontWeight(FontWeight.Medium)
              .fontColor('#1F1B2E').margin({ top: 8 })
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              .width(130)

            Text(album.artist).fontSize(11).fontColor('#6B7280')
              .margin({ top: 2 }).width(130)

            Row() {
              Text(`${(album.rating / 10).toFixed(1)}`).fontSize(11)
                .fontColor(this.getRatingColor(album.rating))
              Blank()
              Text(`${album.songCount}`).fontSize(11).fontColor('#9CA3AF')
            }.width(130).margin({ top: 4 })
          }
          .margin({ right: 16 })
          .onClick(() => {
            router.pushUrl({
              url: 'pages/AlbumPage',
              params: { albumId: album.id }
            });
          })
        }, (album: Album) => album.id.toString())
      }
      .padding({ left: 20, right: 20 })
    }
    .scrollable(ScrollDirection.Horizontal)  // ← 关键:水平滚动
    .height(210)
  }
}

横向滚动三要素

  1. Scroll + scrollable(ScrollDirection.Horizontal)
  2. 内部 Row 作为容器,不限制宽度
  3. 每个卡片固定宽度(130vp),用 margin({ right: 16 }) 控制间距

评分显示rating 存储为整数(如48代表4.8分),通过 (album.rating / 10).toFixed(1) 转为带一位小数的字符串。

3.5 热歌榜 buildTopChart()

@Builder buildTopChart() {
  Column() {
    Row() {
      Text('🏆 热歌榜').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1F1B2E')
      Blank()
      Text('完整榜单 >').fontSize(12).fontColor('#7C3AED')
    }
    .width('100%').padding({ left: 20, right: 20, top: 20 })

    Column() {
      ForEach(this.topSongs, (song: Song, index?: number) => {
        Row() {
          // 序号(前三名红色)
          Text(((index as number) + 1).toString())
            .fontSize(14).fontWeight(FontWeight.Bold)
            .fontColor((index as number) < 3 ? '#EF4444' : '#9CA3AF')
            .width(28)

          Column() {
            Text(song.title).fontSize(14).fontColor('#1F1B2E')
            Text(`${song.artist} · ${song.duration}`)
              .fontSize(11).fontColor('#9CA3AF').margin({ top: 2 })
          }
          .layoutWeight(1).margin({ left: 8 })
          .alignItems(HorizontalAlign.Start)

          Text(this.formatPlays(song.plays)).fontSize(11).fontColor('#6B7280')
        }
        .width('100%').padding({ left: 20, right: 20, top: 10, bottom: 10 })
        .backgroundColor('#FFFFFF').borderRadius(10)
        .margin({ top: 6 })
        .onClick(() => {
          router.pushUrl({
            url: 'pages/AlbumPage',
            params: { albumId: song.albumId > 0 ? song.albumId : 1 }
          });
        })
      }, (song: Song) => song.id.toString())
    }
    .padding({ left: 20, right: 20 })
  }
}

排行序号设计

  • 前三名:红色粗体(#EF4444),突出显示
  • 第4-8名:灰色普通
  • 通过三元表达式 (index < 3 ? '#EF4444' : '#9CA3AF') 实现

播放量格式化

formatPlays(plays: number): string {
  if (plays >= 10000) return (plays / 10000).toFixed(1) + '万';
  return plays.toString();
}

12580 → “1.3万”,9870 → “9870”

3.6 底部导航栏 buildBottomNav()

@Builder buildBottomNav() {
  Row() {
    Column() {
      Text('🎵').fontSize(20)
      Text('首页').fontSize(10).fontColor('#7C3AED').margin({ top: 2 })
    }.layoutWeight(1)

    Column() {  // 发现页
      Text('🔍').fontSize(20)
      Text('发现').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/SearchPage' }); })

    Column() {  // 歌单页
      Text('📋').fontSize(20)
      Text('歌单').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/PlaylistPage' }); })

    Column() {  // 我的
      Text('👤').fontSize(20)
      Text('我的').fontSize(10).fontColor('#9CA3AF').margin({ top: 2 })
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/ProfilePage' }); })
  }
  .width('100%').height(60).backgroundColor('#FFFFFF')
}

四个Tab的图标

  • 🎵 首页(高亮紫色)
  • 🔍 发现
  • 📋 歌单
  • 👤 我的

使用 layoutWeight(1) 实现四等分宽度。首页Tab因为文字是紫色,表示当前所在页面。


四、主布局组装

build(): void {
  Column() {
    this.buildTopBar()       // 问候栏(固定顶部)
    this.buildDateRow()      // 日期(固定顶部)

    Scroll() {               // 中间内容区可滚动
      Column() {
        this.buildBanner()
        this.buildGenreGrid()
        this.buildFeaturedAlbums()
        this.buildTopChart()
      }
      .width('100%').padding({ bottom: 20 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1).width('100%')

    this.buildBottomNav()    // 底部导航(固定底部)
  }
  .width('100%').height('100%').backgroundColor('#F8F7FF')
}

布局层次

Column (100% × 100%)
  ├── buildTopBar()          ← 顶部固定
  ├── buildDateRow()         ← 顶部固定
  ├── Scroll (layoutWeight=1) ← 中间可滚动
  │     └── Banner → Grid → 推荐专辑 → 热歌榜
  └── buildBottomNav()       ← 底部固定

五、辅助方法与工具函数

// 评分颜色映射
getRatingColor(rating: number): string {
  if (rating >= 48) return '#EF4444';  // 高分:红
  if (rating >= 44) return '#F59E0B';  // 中分:橙
  return '#9CA3AF';                     // 低分:灰
}

// 播放量格式化
formatPlays(plays: number): string {
  if (plays >= 10000) return (plays / 10000).toFixed(1) + '万';
  return plays.toString();
}

六、ArkTS严格模式避坑

6.1 Grid的ForEach key

Grid() {
  ForEach(this.genres, (genre: Genre) => {
    GridItem() { /* ... */ }
  }, (genre: Genre) => genre.name)  // key必须是唯一且稳定的
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')

注意:Grid配合ForEach时,rowsTemplatecolumnsTemplate 必须合理设置。如果数据量超过网格容量,多余数据会被忽略。

6.2 Scroll嵌套Column

Scroll内部只能有一个子组件。如果需要展示多个模块,要用一个Column包裹:

Scroll() {
  Column() {     // ← 唯一的直接子组件
    A()
    B()
    C()
  }
}

6.3 评分显示的浮点数

// 避免浮点数运算误差
Text(`${(album.rating / 10).toFixed(1)}`)

toFixed(1) 确保始终显示一位小数,如 4.8、4.5。


七、性能优化提示

  1. Grid数据量控制:8个GridItem用ForEach直接渲染,性能无压力。如果扩展到100+,考虑使用LazyForEach。
  2. 图片占位:当前使用Emoji作为封面占位,实际项目中建议用Image组件 + 懒加载。
  3. Scroll内避免全量渲染:热歌榜Top8数据量小,直接渲染适合。如果是Top100,建议分页加载。

在这里插入图片描述

八、篇末总结

本篇完成了首页全部开发,核心内容包括:

  1. ✅ Grid组件实现8分类 4×2 网格布局
  2. ✅ Scroll + Row 实现横向专辑推荐滚动
  3. ✅ 排行榜序号着色(前三名红色高亮)
  4. ✅ @Builder组件化解耦五大模块
  5. ✅ 时段问候语的动态逻辑
  6. ✅ 多模块在一个Scroll中的垂直滚动组合

下一篇将实现发现页(搜索)与专辑详情页,深入讲解:

  • 多维筛选联动(关键词 + 分类 + 年份)
  • Wrap标签云布局
  • 路由传参跳转动态详情
  • 曲目列表收藏/取消收藏交互

文章索引:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐