第10次:HeroBanner 组件开发

组件化是现代 UI 开发的核心思想。本次课程将把首页的 HeroBanner 抽取为独立的可复用组件,学习组件设计、Props 定义、渐变背景实现等关键技术。


学习目标

  • 理解组件化开发的意义与优势
  • 掌握组件 Props 设计原则
  • 学会使用 @Prop 装饰器传递数据
  • 实现渐变背景效果
  • 完成 HeroBanner 组件的完整开发

10.1 组件化开发概述

为什么要组件化?

在第9次课程中,我们在 Index.ets 中直接编写了 HeroBanner 的代码。随着应用功能增加,这种方式会导致:

  1. 代码臃肿:单个文件代码量过大,难以维护
  2. 复用困难:相同 UI 无法在其他页面使用
  3. 职责不清:页面逻辑与组件逻辑混杂

组件化开发的优势:

优势 说明
可复用性 同一组件可在多处使用
可维护性 修改组件只需改一处
可测试性 组件可独立测试
协作效率 团队成员可并行开发

组件设计原则

  1. 单一职责:一个组件只做一件事
  2. 高内聚低耦合:组件内部紧密,对外依赖少
  3. Props 向下传递:父组件通过 Props 控制子组件
  4. 事件向上传递:子组件通过回调通知父组件

10.2 组件设计与 Props 定义

分析 HeroBanner 需求

HeroBanner 需要展示:

  • 欢迎语和副标题
  • 学习统计(已完成、总课程、连续天数)
  • 每日一题入口

需要从外部接收的数据:

  • completedLessons:已完成课程数
  • totalLessons:总课程数
  • streak:连续学习天数
  • onDailyQuestionTap:每日一题点击回调

Props 定义

@Component
export struct HeroBanner {
  // 使用 @Prop 接收父组件传递的数据
  @Prop completedLessons: number = 0;
  @Prop totalLessons: number = 0;
  @Prop streak: number = 0;
  
  // 访问全局状态
  @StorageLink('isDarkMode') isDarkMode: boolean = false;
  
  // 回调函数(可选)
  onDailyQuestionTap?: () => void;
}

@Prop vs @Link

装饰器 数据流向 使用场景
@Prop 单向(父→子) 子组件只读数据
@Link 双向 子组件需要修改数据

HeroBanner 只需要展示数据,不需要修改,所以使用 @Prop


10.3 渐变背景实现

linearGradient 属性

ArkUI 提供 linearGradient 属性实现线性渐变:

Column()
  .linearGradient({
    angle: 135,           // 渐变角度
    colors: [             // 颜色数组
      ['#61DAFB', 0],     // [颜色, 位置]
      ['#21a0c4', 1]
    ]
  })

渐变角度说明

        0°
        ↑
  315° ←┼→ 45°
        ↓
       180°
  • :从下到上
  • 90°:从左到右
  • 135°:从左上到右下
  • 180°:从上到下

实现渐变背景

@Builder
GradientBackground() {
  Column()
    .width('100%')
    .height('100%')
    .linearGradient({
      angle: 135,
      colors: [['#61DAFB', 0], ['#21a0c4', 1]]
    })
}

使用 Stack 叠加内容

Stack() {
  // 底层:渐变背景
  Column()
    .width('100%')
    .height('100%')
    .linearGradient({
      angle: 135,
      colors: [['#61DAFB', 0], ['#21a0c4', 1]]
    })

  // 上层:内容
  Column() {
    Text('内容')
      .fontColor('#ffffff')
  }
  .padding(20)
}

10.4 学习统计展示

统计区域布局

使用 Row 布局,三个统计项平均分配空间:

Row() {
  // 已完成
  Column() {
    Text(`${this.completedLessons}`)
      .fontSize(28)
      .fontWeight(FontWeight.Bold)
      .fontColor('#ffffff')
    Text('已完成')
      .fontSize(12)
      .fontColor('rgba(255,255,255,0.95)')
  }
  .layoutWeight(1)

  // 分隔线
  Column()
    .width(1)
    .height(40)
    .backgroundColor('rgba(255,255,255,0.3)')

  // 总课程
  Column() {
    Text(`${this.totalLessons}`)
      .fontSize(28)
      .fontWeight(FontWeight.Bold)
      .fontColor('#ffffff')
    Text('总课程')
      .fontSize(12)
      .fontColor('rgba(255,255,255,0.95)')
  }
  .layoutWeight(1)

  // 分隔线
  Column()
    .width(1)
    .height(40)
    .backgroundColor('rgba(255,255,255,0.3)')

  // 连续天数
  Column() {
    Text(`${this.streak}`)
      .fontSize(28)
      .fontWeight(FontWeight.Bold)
      .fontColor('#ffffff')
    Text('连续天数')
      .fontSize(12)
      .fontColor('rgba(255,255,255,0.95)')
  }
  .layoutWeight(1)
}
.width('100%')
.margin({ top: 20 })
.padding({ left: 16, right: 16 })

layoutWeight 说明

layoutWeight 用于在 Row/Column 中按比例分配剩余空间:

Row() {
  Column().layoutWeight(1)  // 占 1/3
  Column().layoutWeight(1)  // 占 1/3
  Column().layoutWeight(1)  // 占 1/3
}

10.5 每日一题入口

入口设计

Row() {
  Text('📝 每日一题')
    .fontSize(14)
    .fontColor('#ffffff')

  Blank()  // 占据中间空间

  Text('挑战 →')
    .fontSize(14)
    .fontColor('rgba(255,255,255,0.95)')
}
.width('100%')
.padding(12)
.margin({ top: 16 })
.backgroundColor('rgba(255,255,255,0.15)')  // 半透明白色背景
.borderRadius(12)
.onClick(() => {
  if (this.onDailyQuestionTap) {
    this.onDailyQuestionTap();
  }
})

回调函数处理

组件通过可选的回调函数与父组件通信:

// 组件定义
onDailyQuestionTap?: () => void;

// 点击时调用
.onClick(() => {
  if (this.onDailyQuestionTap) {
    this.onDailyQuestionTap();
  }
})

// 父组件使用
HeroBanner({
  completedLessons: 5,
  totalLessons: 35,
  streak: 3,
  onDailyQuestionTap: () => {
    router.pushUrl({ url: 'pages/QuizPage' });
  }
})

10.6 实操:完成 HeroBanner.ets 组件

步骤一:创建组件文件

entry/src/main/ets/components/ 目录下创建 HeroBanner.ets 文件。

步骤二:编写完整组件代码

/**
 * 首页横幅组件
 * 显示学习统计和每日一题入口
 */
@Component
export struct HeroBanner {
  // Props:从父组件接收的数据
  @Prop completedLessons: number = 0;
  @Prop totalLessons: number = 0;
  @Prop streak: number = 0;
  
  // 全局状态:主题模式
  @StorageLink('isDarkMode') isDarkMode: boolean = false;
  
  // 回调函数:每日一题点击事件
  onDailyQuestionTap?: () => void;

  build() {
    Column() {
      // 使用 Stack 叠加渐变背景和内容
      Stack() {
        // 渐变背景层
        Column()
          .width('100%')
          .height('100%')
          .linearGradient({
            angle: 135,
            colors: [['#61DAFB', 0], ['#21a0c4', 1]]
          })

        // 内容层
        Column() {
          // 欢迎语
          Text('⚛️ React 学习之旅')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#ffffff')

          Text('由浅入深,系统掌握 React')
            .fontSize(14)
            .fontColor('rgba(255,255,255,0.95)')
            .margin({ top: 4 })

          // 学习统计卡片
          Row() {
            // 已完成课程
            Column() {
              Text(`${this.completedLessons}`)
                .fontSize(28)
                .fontWeight(FontWeight.Bold)
                .fontColor('#ffffff')
              Text('已完成')
                .fontSize(12)
                .fontColor('rgba(255,255,255,0.95)')
            }
            .layoutWeight(1)

            // 分隔线
            Column()
              .width(1)
              .height(40)
              .backgroundColor('rgba(255,255,255,0.3)')

            // 总课程
            Column() {
              Text(`${this.totalLessons}`)
                .fontSize(28)
                .fontWeight(FontWeight.Bold)
                .fontColor('#ffffff')
              Text('总课程')
                .fontSize(12)
                .fontColor('rgba(255,255,255,0.95)')
            }
            .layoutWeight(1)

            // 分隔线
            Column()
              .width(1)
              .height(40)
              .backgroundColor('rgba(255,255,255,0.3)')

            // 连续天数
            Column() {
              Text(`${this.streak}`)
                .fontSize(28)
                .fontWeight(FontWeight.Bold)
                .fontColor('#ffffff')
              Text('连续天数')
                .fontSize(12)
                .fontColor('rgba(255,255,255,0.95)')
            }
            .layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 20 })
          .padding({ left: 16, right: 16 })

          // 每日一题入口
          Row() {
            Text('📝 每日一题')
              .fontSize(14)
              .fontColor('#ffffff')

            Blank()

            Text('挑战 →')
              .fontSize(14)
              .fontColor('rgba(255,255,255,0.95)')
          }
          .width('100%')
          .padding(12)
          .margin({ top: 16 })
          .backgroundColor('rgba(255,255,255,0.15)')
          .borderRadius(12)
          .onClick(() => {
            if (this.onDailyQuestionTap) {
              this.onDailyQuestionTap();
            }
          })
        }
        .width('100%')
        .padding(20)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .height(220)
      .borderRadius({ bottomLeft: 24, bottomRight: 24 })
      .clip(true)  // 裁剪超出圆角的内容
    }
    .width('100%')
  }
}

步骤三:在首页中使用组件

更新 Index.ets,导入并使用 HeroBanner 组件:

// 导入组件
import { HeroBanner } from '../components/HeroBanner';

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State completedCount: number = 0;
  @State totalCount: number = 35;
  @State streakDays: number = 0;
  @StorageLink('isDarkMode') isDarkMode: boolean = false;

  build() {
    Column() {
      Tabs({ barPosition: BarPosition.End, index: this.currentTab }) {
        TabContent() {
          this.HomeContent()
        }
        .tabBar(this.TabBuilder('🏠', '首页', 0))
        
        // ... 其他 Tab
      }
    }
  }

  @Builder
  HomeContent() {
    Scroll() {
      Column() {
        // 使用 HeroBanner 组件
        HeroBanner({
          completedLessons: this.completedCount,
          totalLessons: this.totalCount,
          streak: this.streakDays,
          onDailyQuestionTap: () => {
            // 跳转到每日一题页面
            router.pushUrl({ url: 'pages/QuizPage' });
          }
        })

        // 其他内容...
      }
    }
  }
}

步骤四:验证组件效果

  1. 运行应用,查看首页 HeroBanner 显示效果
  2. 检查渐变背景是否正确显示
  3. 点击"每日一题"入口,验证回调是否触发
  4. 修改传入的数据,验证组件是否正确更新

组件代码解析

关键点总结

  1. @Component + export:使组件可被其他文件导入
@Component
export struct HeroBanner {
  // ...
}
  1. @Prop 默认值:为 Props 提供默认值,避免未传参时报错
@Prop completedLessons: number = 0;
  1. 可选回调:使用 ? 标记回调为可选
onDailyQuestionTap?: () => void;
  1. 安全调用:调用回调前检查是否存在
if (this.onDailyQuestionTap) {
  this.onDailyQuestionTap();
}
  1. clip(true):配合 borderRadius 裁剪内容
.borderRadius({ bottomLeft: 24, bottomRight: 24 })
.clip(true)

本次课程小结

通过本次课程,你已经:

✅ 理解了组件化开发的意义与优势
✅ 掌握了组件 Props 设计原则
✅ 学会了使用 @Prop 装饰器传递数据
✅ 实现了渐变背景效果
✅ 完成了 HeroBanner 组件的完整开发


课后练习

  1. 添加动画效果:为统计数字添加数字滚动动画

  2. 主题适配:根据 isDarkMode 调整渐变颜色

  3. 扩展 Props:添加自定义标题和副标题的 Props


下次预告

第11次:ModuleCard 模块卡片组件

我们将开发另一个核心组件 ModuleCard:

  • 卡片组件设计
  • 难度等级标签
  • 进度条展示
  • 锁定状态处理

继续深入组件化开发!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐