鸿蒙原生应用实战(二):首页开发 —— 周历导航与@Builder组件化实践

鸿蒙原生应用实战(二):首页开发 ------ 周历导航与@Builder组件化实践

前言

在上一篇中,我们完成了项目初始化与Stage模型架构设计。本篇将进入核心开发环节------首页(Index.ets) 的实现。

首页是用户启动App后看到的第一个页面,承载着三个核心功能模块:

  1. 顶部周历导航栏 ------ 显示当前日期、星期切换
  2. 今日更新列表 ------ 按所选星期显示更新剧集
  3. 热门推荐区域 ------ 横向滚动展示热门剧集

我将带你从ArkTS组件化思维出发,完整实现这三大模块,并深入讲解@Builder的使用技巧。


一、首页整体框架

1.1 页面结构规划

复制代码
Index.ets
├── @Builder buildNavBar()        ← 顶部导航栏(日期 + 星期选择)
│   ├── Row: 日期显示
│   ├── Row: 标题"追剧日历"
│   └── List: 七天星期切换
├── @Builder buildTodayDramas()   ← 今日更新剧集列表
│   ├── Row: 标题 + 剧集数量
│   └── ForEach: 剧集卡片列表
├── @Builder buildHotSection()    ← 热门推荐
│   ├── Row: 标题"热门推荐"
│   └── Scroll→Row: 横向滚动卡片
├── @Builder buildBottomNav()     ← 底部导航栏
│   └── Row: 四个Tab图标
└── build()                       ← 主布局组合

1.2 数据接口定义

typescript 复制代码
interface Drama {
  id: number;              // 唯一标识
  title: string;           // 剧集名称
  cover: string;           // 封面图(预留字段)
  genre: string;           // 类型
  episodes: number;        // 总集数
  watchedEpisodes: number; // 已看集数
  status: string;          // 状态:连载中/已完结
  rating: number;          // 评分(5分制÷10,如47=4.7分)
  updateDay: string;       // 更新日:周一~周日
  isNew: boolean;          // 是否为新剧
}

这里 rating 使用整数存储(如47代表4.7分),避免浮点数精度问题。cover 预留为字符串,后续可从网络加载图片URL。


二、@State状态管理与初始化

2.1 状态变量声明

typescript 复制代码
@Component
struct Index {
  @State currentDate: string = '2025-01-15';  // 当前日期显示
  @State dramas: Drama[] = [];                 // 全部剧集数据
  @State hotDramas: Drama[] = [];              // 热门剧集数据
  @State selectedDay: string = '周一';          // 当前选中的星期
  @State weekDays: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
}

@State装饰器的作用 :当被装饰的变量值发生变化时,ArkTS框架会自动触发UI刷新。这是声明式UI的核心机制------数据驱动视图

2.2 数据初始化

typescript 复制代码
aboutToAppear(): void {
  this.initDramas();
  this.initHotDramas();
  this.updateCurrentDate();
}

aboutToAppear 是ArkTS组件生命周期方法,在组件即将显示时调用。它等同于 onPageShow,适合在此处加载数据。

2.3 日期处理

typescript 复制代码
updateCurrentDate(): void {
  const now: Date = new Date();
  const year: number = now.getFullYear();
  const month: number = now.getMonth() + 1;   // getMonth()返回0-11
  const day: number = now.getDate();
  const dayOfWeek: number = now.getDay();      // getDay()返回0(周日)-6(周六)
  const weekMap: string[] = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
  this.currentDate = `${year}年${month < 10 ? '0' + month : month}月${day < 10 ? '0' + day : day}日 ${weekMap[dayOfWeek]}`;
  this.currentDayIndex = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
  this.selectedDay = this.weekDays[this.currentDayIndex];
}

关键点

  • getMonth() 返回0-11,需要 +1 才是实际月份
  • getDay() 返回0表示周日,需要映射到我们的数组索引
  • 使用条件表达式处理个位数的月份/日期补零

2.4 模拟数据

typescript 复制代码
initDramas(): void {
  this.dramas = [
    { id: 1, title: '星落凝成糖', genre: '古装仙侠', episodes: 40, watchedEpisodes: 12,
      status: '连载中', rating: 47, updateDay: '周一', isNew: true },
    // ... 共8条模拟数据,覆盖周一到周六的更新
  ];
}

initHotDramas(): void {
  this.hotDramas = [
    { id: 9, title: '狂飙', genre: '犯罪悬疑', episodes: 39, status: '已完结', rating: 49, ... },
    // ... 共4条热门剧集数据
  ];
}

开发阶段使用模拟数据的优势

  • 不依赖后端接口,可独立开发UI
  • 数据可控,方便测试各种边界情况
  • 后续只需替换为网络请求即可

三、@Builder组件化实践

3.1 什么是@Builder

@Builder 是ArkTS中定义可复用的UI片段的方法装饰器。与提取自定义组件相比:

  • @Builder更轻量,不需要额外的struct定义
  • 可以直接访问所在组件的成员变量和方法
  • 适合封装页面内的功能模块

3.2 顶部导航栏 buildNavBar()

typescript 复制代码
@Builder buildNavBar() {
  Column() {
    // --------- 第一行:日期显示 ---------
    Row() {
      Text(this.currentDate)
        .fontSize(14)
        .fontColor('#666666')
      Blank()          // 弹性空白,撑开两侧
      Image("")
        .width(24).height(24).borderRadius(12)  // 用户头像占位
    }
    .width('100%').padding({ left: 16, right: 16 })

    // --------- 第二行:标题 ---------
    Row() {
      Text('追剧日历')
        .fontSize(24).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
      Blank()
      Image("").width(22).height(22).opacity(0)  // 对称占位,保持标题居中
    }
    .width('100%').padding({ left: 16, right: 16, top: 8 })

    // --------- 第三行:星期选择器 ---------
    List() {
      ForEach(this.weekDays, (day: string) => {
        ListItem() {
          Column() {
            Text(day)
              .fontSize(13)
              .fontColor(this.selectedDay === day ? '#FFFFFF' : '#333333')
          }
          .width(40).height(32).borderRadius(16)
          .backgroundColor(this.selectedDay === day ? '#FF6B35' : '#F0F0F0')
          .onClick(() => { this.selectedDay = day; })
        }
      }, (day: string) => day)
    }
    .listDirection(Axis.Horizontal)
    .height(44).padding({ left: 12, right: 12, top: 8 })
  }
  .width('100%')
}

设计要点

a) Blank()的妙用

Blank() 是一个弹性空白组件,会自动占据Row中的剩余空间。在日期行中,它将日期推到左侧,头像占位推到右侧。

b) 对称占位技巧

标题行中左侧是"追剧日历"Text,右侧放了一个透明Image来保持视觉平衡。如果右侧没有元素,标题会偏左,整体观感不对称。

c) List + ForEach实现标签导航

使用 List 组件并设置 listDirection(Axis.Horizontal) 实现横向滚动标签栏。当标签数量超过屏幕宽度时自动支持滑动,比直接使用 Row 更稳健。

d) 条件样式

通过对比 this.selectedDay === day 来决定Text颜色和背景色,实现选中态高亮。

3.3 今日更新区域 buildTodayDramas()

typescript 复制代码
@Builder buildTodayDramas() {
  Column() {
    // --------- 区域标题 ---------
    Row() {
      Text(`📺 ${this.selectedDay}更新`)  // 动态拼接标题
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
      Blank()
      Text(`${this.getDayDramas().length}部`)  // 动态统计数量
        .fontSize(12).fontColor('#FF6B35')
    }
    .width('100%').padding({ left: 16, right: 16, top: 12 })

    // --------- 剧集卡片列表 ---------
    ForEach(this.getDayDramas(), (item: Drama) => {
      Stack() {
        Row() {
          Column() {
            // 剧名
            Text(item.title).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A2E')
            // 类型 + 已看/总集数
            Row() {
              Text(item.genre).fontSize(11).fontColor('#999999')
              Blank()
              Text(`${item.watchedEpisodes}/${item.episodes}集`).fontSize(11).fontColor('#FF6B35')
            }.width('100%').margin({ top: 6 })
            // 状态标签 + 评分
            Row() {
              Text(item.status)
                .fontSize(11).fontColor(Color.White)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(this.getStatusColor(item.status)).borderRadius(8)
              Blank()
              Text(item.rating.toString()).fontSize(12).fontWeight(FontWeight.Bold)
                .fontColor(this.getRatingColor(item.rating))
            }.width('100%').margin({ top: 6 })
            // 追剧进度条
            Progress({ value: this.getProgressPercent(item), total: 100, style: ProgressStyle.Linear })
              .width('100%').height(4).color('#FF6B35').backgroundColor('#F0F0F0').borderRadius(2)
              .margin({ top: 6 })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
        }
        .width('100%').padding(14)
        .backgroundColor('#FFFFFF').borderRadius(12)

        // "新"标签(条件渲染)
        if (item.isNew) {
          Text('新').fontSize(10).fontColor(Color.White)
            .padding({ left: 5, right: 5, top: 2, bottom: 2 })
            .backgroundColor('#FF4757').borderRadius(4)
            .position({ top: 8, right: 8 })  // 绝对定位在卡片右上角
        }
      }
      .width('100%').margin({ top: 8 })
      .onClick(() => {
        router.pushUrl({ url: 'pages/DetailPage', params: { dramaId: item.id } });
      })
    }, (item: Drama) => item.id.toString())
  }
  .width('100%')
}

核心组件详解

a) Progress进度条

typescript 复制代码
Progress({ value: percent, total: 100, style: ProgressStyle.Linear })
  • value: 当前进度值
  • total: 总进度值(100)
  • style: 样式,支持 Linear(线性)、Ring(环形)等

进度条展示了追剧进度,视觉上让用户一目了然地知道每部剧追了多少。

b) Stack + position实现角标

typescript 复制代码
Stack() {
  // 卡片主体...
  if (item.isNew) {
    Text('新').position({ top: 8, right: 8 })
  }
}

Stack 是一个层叠布局容器,子组件按顺序叠放。通过 position() 设置绝对定位,将"新"标签放在卡片右上角。

c) getDayDramas() ------ 数据筛选方法

typescript 复制代码
getDayDramas(): Drama[] {
  return this.dramas.filter((item: Drama) => item.updateDay === this.selectedDay);
}

这是一个计算属性 ,每次调用都会根据当前 selectedDay 重新筛选。当用户点击不同的星期时,selectedDay 变化触发UI刷新,列表实时更新。

3.4 热门推荐区域 buildHotSection()

typescript 复制代码
@Builder buildHotSection() {
  Column() {
    Row() {
      Text('🔥 热门推荐').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1A1A2E')
      Blank()
      Text('更多 >').fontSize(12).fontColor('#FF6B35')
    }
    .width('100%').padding({ left: 16, right: 16, top: 16 })

    // 横向滚动容器
    Scroll() {
      Row() {
        ForEach(this.hotDramas, (item: Drama) => {
          Column() {
            // 封面占位(带Emoji图标)
            Stack() {
              Column().width(100).height(140)
                .backgroundColor('#E8E8E8').borderRadius(8)
              Text('🎬').fontSize(36)
            }.width(100).height(140)

            Text(item.title).fontSize(13).fontWeight(FontWeight.Medium)
              .fontColor('#333333').margin({ top: 6 })
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

            Row() {
              Text(item.genre).fontSize(11).fontColor('#999999')
              Blank()
              Text(`评分${item.rating}`).fontSize(11).fontColor('#E74C3C')
            }.width(100)
          }
          .margin({ right: 12 })
          .onClick(() => {
            router.pushUrl({ url: 'pages/DetailPage', params: { dramaId: item.id } });
          })
        }, (item: Drama) => item.id.toString())
      }
      .padding({ left: 16, right: 16 })
    }
    .scrollable(ScrollDirection.Horizontal)  // 关键:水平滚动
    .height(210)
  }
  .width('100%')
}

横向滚动的实现机制

复制代码
Scroll(scrollable: ScrollDirection.Horizontal)
  └── Row
       ├── Column (卡片1)
       ├── Column (卡片2)
       └── Column (卡片3) ...

要点:

  1. Scrollscrollable 属性设为 ScrollDirection.Horizontal
  2. Row 作为子容器,内部 Column 卡片无需指定固定宽度,而是由内容撑开
  3. 每个卡片设置 margin({ right: 12 }) 控制间距
  4. 整行通过 padding 控制左右边距

四、底部导航栏设计

typescript 复制代码
@Builder buildBottomNav() {
  Row() {
    Column() {
      Text('🏠').fontSize(20); Text('首页').fontSize(10).fontColor('#FF6B35')
    }.layoutWeight(1)

    Column() {
      Text('🔍').fontSize(20); Text('搜索').fontSize(10).fontColor('#999999')
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/SearchPage' }); })

    Column() {
      Text('📋').fontSize(20); Text('我的追剧').fontSize(10).fontColor('#999999')
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/MyListPage' }); })

    Column() {
      Text('📊').fontSize(20); Text('统计').fontSize(10).fontColor('#999999')
    }.layoutWeight(1).onClick(() => { router.pushUrl({ url: 'pages/StatsPage' }); })
  }
  .width('100%').height(60).backgroundColor('#FFFFFF')
}

布局要点

  • 使用 layoutWeight(1) 均匀分配四个Tab的宽度
  • 图标使用Emoji简化(无需引入图标库)
  • "首页"Tab高亮色表示当前页面
  • 其他Tab绑定 router.pushUrl 跳转到对应页面

五、主布局组装

typescript 复制代码
build(): void {
  Column() {
    this.buildNavBar()           // 顶部导航栏(不滚动)
    Scroll() {
      Column() {
        this.buildTodayDramas()  // 今日更新
        this.buildHotSection()   // 热门推荐
      }.width('100%').padding({ bottom: 20 })
    }
    .scrollable(ScrollDirection.Vertical)
    .layoutWeight(1)
    .width('100%')

    this.buildBottomNav()        // 底部导航栏(不滚动)
  }
  .width('100%').height('100%').backgroundColor('#F5F5F5')
}

布局层次

复制代码
Column (100% × 100%)
  ├── buildNavBar()         ← 固定顶部
  ├── Scroll (layoutWeight=1) ← 中间内容区,可滚动
  │     └── buildTodayDramas() + buildHotSection()
  └── buildBottomNav()      ← 固定底部

layoutWeight(1) 让Scroll占满剩余空间,这是Flex布局的经典用法。


六、辅助方法

6.1 颜色映射方法

typescript 复制代码
getStatusColor(status: string): ResourceStr {
  if (status === '连载中') return '#FF6B35';  // 橙色:进行中
  if (status === '已完结') return '#4CAF50';  // 绿色:已完成
  return '#999999';                            // 灰色:其他
}

getRatingColor(rating: number): ResourceStr {
  if (rating >= 48) return '#E74C3C';  // 高评分:红色
  if (rating >= 44) return '#F39C12';  // 中评分:橙色
  return '#95A5A6';                     // 低评分:灰色
}

返回类型 ResourceStr :既可以返回资源引用 $r('app.color.xxx'),也可以返回颜色字符串 '#FF6B35'

6.2 进度计算方法

typescript 复制代码
getProgressPercent(item: Drama): number {
  if (item.episodes === 0) return 0;  // 防止除以0
  return Math.round((item.watchedEpisodes / item.episodes) * 100);
}

边界情况处理 :当 episodes 为0时直接返回0,避免出现 NaN


七、ArkTS严格模式避坑

7.1 对象字面量类型推断

在ArkTS严格模式下(arkts-no-untyped-obj-literals),直接写对象字面量会编译报错:

typescript 复制代码
// ❌ 编译错误:未类型化的对象字面量不可用
this.dramas = [{ id: 1, title: '...' }];

// ✅ 正确:前提是 dramas 声明为 Drama[] 类型
this.dramas = [{ id: 1, title: '...' }];  // 自动推断为 Drama 类型

7.2 ForEach的key生成

typescript 复制代码
// ✅ 每个列表项必须有唯一key
ForEach(this.dramas, (item: Drama) => {
  // ...
}, (item: Drama) => item.id.toString())

第三个参数是key生成函数,用于Diff算法识别。缺少key或key不唯一会导致列表渲染性能问题和状态错乱。

7.3 数组推导式

typescript 复制代码
// 不使用ArkTS的...展开运算符时,用for循环替代
// ⚠️ 注意:某些版本不支持 [...array] 语法
const newArray: Drama[] = [];
for (let i: number = 0; i < this.dramas.length; i++) {
  newArray.push(this.dramas[i]);
}
this.dramas = newArray;

八、性能优化建议

8.1 减少不必要的状态变量

typescript 复制代码
// ❌ 不推荐:用状态变量存储衍生数据
@State dayDramasCount: number = 0;

// ✅ 推荐:直接通过方法计算,避免同步问题
getDayDramas(): Drama[] { ... }

8.2 合理使用key

ForEach 中始终提供稳定且唯一的key。使用 id 比使用 index(下标)更可靠,因为列表项位置可能变化。

8.3 Scroll嵌套的性能考量

Scroll 内部嵌套大量列表项时,考虑:

  • 使用 LazyForEach 替代 ForEach(大数据量时)
  • 避免在列出项中使用复杂动画
  • 减少嵌套层级

本项目中数据量较小(<20条),使用 ForEach 完全足够。


九、篇末总结

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

  1. ✅ @Builder装饰器实现UI组件化
  2. ✅ List + ForEach构建横向星期选择器
  3. ✅ Scroll + Row实现横向滚动推荐区
  4. ✅ Progress进度条组件展示追剧进度
  5. ✅ Stack + position实现角标效果
  6. ✅ Blank()弹性空间在布局中的应用

下一篇将实现搜索页与详情页,深入讲解:

  • 多维度组合筛选算法
  • 路由传参与动态数据加载
  • 分集列表的勾选/取消逻辑
  • Tab切换的多内容展示

文章索引:

相关推荐
hahjee2 小时前
【鸿蒙PC】kcp 移植:AtomCode Skills 4 步速通单文件 C 库适配
c语言·华为·harmonyos
风满城332 小时前
鸿蒙原生应用实战(四):歌单管理 —— 创建歌单与歌曲编排
华为·harmonyos
木咺吟3 小时前
鸿蒙原生应用开发实战(三):电影列表与搜索筛选 — 电影清单App
harmonyos
金启攻3 小时前
鸿蒙原生应用实战(一):项目初始化与Stage模型架构设计
华为·harmonyos
seal_jing3 小时前
44岁被裁后用AI写鸿蒙App(5):一个页面的App,真的能搞定一切吗
harmonyos
坚果派·白晓明3 小时前
鸿蒙PC】libuv适配:AtomCode Skills一站式指南
c语言·c++·华为·ai编程·harmonyos·atomcode
FrameNotWork3 小时前
HarmonyOS 6.1 Canvas粒子效果系统从零实现
华为·harmonyos
祭曦念4 小时前
宠物成长日记_鸿蒙开发实战
华为·harmonyos·宠物
又至冬日4 小时前
鸿蒙(HarmoneyOS),封装一个通用关系型数据库操作类
数据库·oracle·harmonyos