编程实验:仿写鸿蒙端 B站首页
使用 HarmonyOS ArkUI 组件与布局能力,仿写 B站 APP 首页的 UI 效果。
最终效果包含以下区域:
┌──────────────────────────────────────────┐│ [TV] 搜索感兴趣的内容 铃 信 │ ← A 顶部 Header├──────────────────────────────────────────┤│ 直播 推荐 热门 动画 影视 更多 ... │ ← B 分类 Tab 栏(可横向滑动)├──────────────────────────────────────────┤│ ┌────────────────────────────────────┐ ││ │ │ ││ │ 正在推荐 · T1选手的中文名字 │ │ ← C Banner 轮播(自动播放)│ │ │ ││ └────────────────────────────────────┘ │├──────────────────────────────────────────┤│ ┌──────────┐ ┌──────────┐ ││ │ [封面图] │ │ [封面图] │ ││ │ ▶播放量 时│ │ ▶播放量 时│ │ ← D 两列视频卡片(可纵向滚动)│ │ 视频标题 │ │ 视频标题 │ ││ │ • 作者名 │ │ • 作者名 │ ││ └──────────┘ └──────────┘ │├──────────────────────────────────────────┤│ 首页 关注 [+] 会员购 我的 │ ← E 底部导航栏└──────────────────────────────────────────┘考察知识点
| 知识点 | 在本练习中的使用场景 |
|---|---|
Column / Row | 页面整体纵向布局、Header 横向排布、导航栏横向排布 |
@Component + 文件拆分 | 将 VideoCard、VideoListContent、HomePage 抽取到独立文件 |
Tabs + TabContent | 首页分类 Tab 栏(直播 / 推荐 / 热门 …) |
@Builder + 参数 | 自定义 Tab 标签样式、底部导航项 |
@State + onChange / onClick | Tab 激活态颜色切换、底部导航页面切换 |
List + ListItem | 视频卡片列表可纵向滚动 |
ForEach | 批量渲染视频对、Tab 标签、Banner 图片 |
Swiper | Banner 轮播图(autoPlay / interval / loop) |
Stack + Alignment | 封面图上叠加浮层信息 |
linearGradient | 封面浮层半透明渐变遮罩 |
aspectRatio | 封面图保持 16:9 宽高比 |
layoutWeight | 两列卡片等宽分配、内容区填满剩余高度 |
maxLines + textOverflow | 视频标题超出两行时自动省略 |
| 数据与 UI 分离 | 数据模型与常量统一在 VideoData.ets 中管理 |
任务要求
- 完整实现效果图中所有区域的 UI,包括:顶部 Header、分类 Tab 栏、Banner 轮播、两列视频卡片、底部导航栏
- 必须将各功能区域拆分为独立的
.ets文件,不允许全部写在Index.ets中 - 必须使用
ForEach渲染视频列表,不允许手动重复编写多张卡片 - Banner 必须实现自动轮播(使用
Swiper+autoPlay) - 分类 Tab 的激活态必须响应点击(使用
@State+onChange) - 底部导航栏点击必须切换页面(使用
@State activeTab+if/else)
2.1 获取模板工程
📦 模板工程仓库:
https://cnb.cool/sziit-coding/harmony-coding/bilibili
请将以上工程导入到DevEco Studio中,并运行此项目。
2.2 模板工程内容说明
模板工程中已预置以下内容,无需自行创建或导入:
图片资源(位于 entry/src/main/resources/base/media/)
| 文件名 | 说明 |
|---|---|
ic_bell.svg | 铃铛(通知)图标 |
ic_message.svg | 私信图标 |
tab_home_normal.svg | 首页导航图标(未选中) |
tab_home_selected.svg | 首页导航图标(选中,粉色) |
tab_follow_normal.svg | 关注导航图标(未选中) |
tab_follow_selected.svg | 关注导航图标(选中) |
tab_vip_normal.svg | 会员购导航图标(未选中) |
tab_vip_selected.svg | 会员购导航图标(选中) |
tab_me_normal.svg | 我的导航图标(未选中) |
tab_me_selected.svg | 我的导航图标(选中) |
数据文件(位于 entry/src/main/ets/viewmodel/)
VideoData.ets 已完整提供,包含:
| 导出内容 | 说明 |
|---|---|
class VideoItem | 视频数据模型(id / title / author / views / cover / duration / danmaku) |
class VideoPairItem | 视频对模型(用于两列网格布局) |
BILIBILI_VIDEO_LIST | 20 条视频数据 |
BANNER_COVERS | 3 张 Banner 图片 URL |
BANNER_TITLES | 3 条 Banner 标题 |
无需修改 VideoData.ets,直接导入使用即可。
入口骨架(位于 entry/src/main/ets/pages/Index.ets)
模板已提供最基础的入口骨架:
@Entry@Componentstruct Index { build() { Column() { Text('首页 - 开发中') .fontSize(20) .fontColor('#CCCCCC') } .width('100%') .height('100%') .backgroundColor('#F4F5F7') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) }}需要学生自行创建的文件
| 文件路径 | 说明 |
|---|---|
entry/src/main/ets/components/HomePage.ets | 首页组件(Header + Tabs) |
entry/src/main/ets/components/VideoListContent.ets | 内容列表(Banner + 视频网格) |
entry/src/main/ets/components/VideoCard.ets | 单张视频卡片组件 |
2.3 确认初始运行效果
- 打开 DevEco Studio,导入模板工程
- 等待依赖安装完成
- 点击右上角 Previewer 图标,打开实时预览面板
- 确认页面显示
#F4F5F7浅灰色背景 + 居中「首页 - 开发中」文字
✅ 预期效果:灰色背景,屏幕中央出现占位文字。
3.1 Tabs — 可切换标签页
Tabs 是 ArkUI 中实现多标签切换的容器组件,配合 TabContent 使用。
@State activeIdx: number = 0
Tabs({ index: this.activeIdx }) { TabContent() { Text('第一页内容') } .tabBar('标签一')
TabContent() { Text('第二页内容') } .tabBar('标签二')}.barMode(BarMode.Scrollable).barHeight(40).onChange((index: number) => { this.activeIdx = index})自定义 Tab 样式:
@BuildermyTab(label: string, index: number) { Column({ space: 4 }) { Text(label) .fontColor(this.activeIdx === index ? '#FB7299' : '#333333') .fontWeight(this.activeIdx === index ? FontWeight.Bold : FontWeight.Normal) Divider() .strokeWidth(2) .width(16) .color(this.activeIdx === index ? '#FB7299' : Color.Transparent) } .padding({ left: 8, right: 8, top: 4, bottom: 4 })}3.2 List + ListItem — 可滚动列表
List() { ListItem() { Text('第一项') } ListItem() { Text('第二项') }}.width('100%').height('100%').scrollBar(BarState.Off)配合 ForEach 批量渲染:
private items: string[] = ['A', 'B', 'C']
List() { ForEach(this.items, (item: string) => { ListItem() { Text(item) } })}3.3 Swiper — 自动轮播
Swiper() { Image('https://example.com/img1.jpg').width('100%').height(192) Image('https://example.com/img2.jpg').width('100%').height(192)}.autoPlay(true).interval(3000).loop(true).height(192).borderRadius(4).clip(true).indicator(true)3.4 Stack — 层叠布局(图片浮层)
Stack({ alignContent: Alignment.Bottom }) { Image('...') .width('100%') .height(192)
Text('浮层文字') .fontColor(Color.White) .padding({ left: 8, bottom: 8 }) .width('100%') .linearGradient({ direction: GradientDirection.Top, colors: [['#BB000000', 0.0], ['#00000000', 1.0]] })}目标:完成底部导航栏,点击可切换「首页」与各占位页面。
4.1 新建 HomePage.ets 占位组件
在 entry/src/main/ets/components/HomePage.ets 中创建首页组件的初始占位:
@Componentexport struct HomePage { build() { Column() { Text('首页 - 开发中') .fontSize(20) .fontColor('#CCCCCC') } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .backgroundColor('#F4F5F7') }}这个组件是临时占位,后续步骤 5~9 会逐步完善它的内容。
4.2 在 Index.ets 中导入组件、添加状态变量
打开 entry/src/main/ets/pages/Index.ets,在文件顶部添加导入:
// Index.ets — 文件顶部import { HomePage } from '../components/HomePage'
@Entry@Componentstruct Index { ...}在 Index 结构体内添加状态变量:
@Entry@Componentstruct Index { @State activeTab: number = 0
...}
activeTab记录当前选中的导航标签(0 = 首页,1 = 关注,3 = 会员购,4 = 我的),点击时同步更新。
4.3 在文件末尾添加 PlaceholderPage
build() 中的内容区需要用到 PlaceholderPage,先在 Index.ets 文件末尾(Index 结构体外部)把它写好:
// Index.ets — 文件末尾(Index 结构体闭合 } 之后)@Componentstruct PlaceholderPage { label: string = ''
build() { Column() { Text(this.label) .fontSize(24) .fontColor('#CCCCCC') .margin({ bottom: 8 }) Text('暂无内容') .fontSize(14) .fontColor('#CCCCCC') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor('#F4F5F7') }}注意:
PlaceholderPage是struct(不加export),只在Index.ets文件内部使用。
4.4 实现 build() —— 内容区与页面切换
在 Index 的 build() 方法中,先写上方内容区(上半部分):
// Index.ets@Entry@Componentstruct Index { ... build() { Column() { // 内容区,根据 activeTab 决定显示哪个页面 Column() { if (this.activeTab === 0) { HomePage() } else { PlaceholderPage({ label: this.activeTab === 1 ? '关注' : this.activeTab === 3 ? '会员购' : '我的' }) } } .layoutWeight(1) // 占满导航栏以上的所有高度 .width('100%') // ↓ 底部导航栏 —— 4.6 步骤完成 ... } ... }}运行APP,页面效果如下
4.5 编写 @Builder navItem(可复用导航项)
在 Index 结构体内、activeTab 状态变量下方添加 Builder 方法:
// Index.ets@Entry@Componentstruct Index { ... @Builder navItem(normalRes: Resource, selectedRes: Resource, label: string, idx: number) { Column({ space: 4 }) { Image(this.activeTab === idx ? selectedRes : normalRes) .width(24) .height(24) Text(label) .fontSize(10) .fontColor(this.activeTab === idx ? '#FB7299' : '#888888') } .layoutWeight(1) .height('100%') .justifyContent(FlexAlign.Center) .onClick(() => { this.activeTab = idx }) }
build() { ... }}通过
this.activeTab === idx切换选中/未选中状态的图标和文字颜色,点击时执行this.activeTab = idx更新状态。
4.6 实现底部导航栏 Row
在 build() 的内容区 Column 之后(// ↓ 底部导航栏 注释处),填入底部导航栏:
// Index.etsstruct Index { ... build() { Column() { Column() { ... } // 内容区(4.4 已完成)
Row() { // 左侧的两个导航项 this.navItem($r('app.media.tab_home_normal'), $r('app.media.tab_home_selected'), '首页', 0) this.navItem($r('app.media.tab_follow_normal'), $r('app.media.tab_follow_selected'), '关注', 1) // 中间的「+」发布按钮(不参与 activeTab 切换,单独实现) Column() { Row() { Text('+') .fontSize(22) .fontColor(Color.White) .fontWeight(FontWeight.Bold) } .width(48) .height(32) .backgroundColor('#FB7299') .borderRadius(8) .justifyContent(FlexAlign.Center) } .layoutWeight(1) .height('100%') .justifyContent(FlexAlign.Center) // 右边的两个导航项 this.navItem($r('app.media.tab_vip_normal'), $r('app.media.tab_vip_selected'), '会员购', 3) this.navItem($r('app.media.tab_me_normal'), $r('app.media.tab_me_selected'), '我的', 4) } .width('100%') .height(56) .backgroundColor(Color.White) .border({ width: { top: 0.5 }, color: '#E8E8E8' }) } ... }}导航栏共 5 个位置:首页 / 关注 / [+] / 会员购 / 我的。中间「+」使用粉色圆角矩形样式,不参与页面切换。
✅ 预期效果:底部导航栏显示「首页 / 关注 / + / 会员购 / 我的」,点击后可切换首页与各占位页面,选中项变为粉色。
目标:在 HomePage.ets 的 build() 中实现「头像占位圆 + 搜索框 + 铃铛 + 私信」横排 Header。
5.1 分析 Header 的布局结构
在动手之前,先理解 Header 的三个区域:
Row({ space: 8 })├── Stack ─── 头像占位圆(Circle + 'TV' 文字叠加)├── Row ───── 搜索框 .layoutWeight(1) ← 撑满剩余空间└── Image × 2 ── 铃铛 + 私信图标(固定 24×24)💡 搜索框上的
.layoutWeight(1)是关键难点:它让搜索框自动填满头像与图标之间的所有剩余宽度,图标被自然推到屏幕右侧。
5.2 搭建外层 Row 与头像占位圆
打开 entry/src/main/ets/components/HomePage.ets,将 build() 中的占位 Text('首页 - 开发中') 删除,替换为 Header 外层结构:
// HomePage.ets@Componentexport struct HomePage { build() { Column() { // 删除原来的组件 Text('首页 - 开发中') .fontSize(20) .fontColor('#CCCCCC')
// 增加顶部栏行容器以及左侧的头像 Row({ space: 8 }) { // ① 头像占位圆 Stack() { Circle() .width(36) .height(36) .fill('#E8E8E8') Text('TV') .fontSize(10) .fontColor('#AAAAAA') .fontWeight(FontWeight.Bold) } // ② 搜索框 —— 5.3 步骤完成 // ③ 铃铛 + 私信图标 —— 5.4 步骤完成 } .width('100%') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .backgroundColor(Color.White) } .width('100%') .height('100%') .backgroundColor('#F4F5F7') }}第 6~23 行是本步骤需要新增的代码,其中最外层
Column和样式属性原模板已有。
✅ 预期效果:顶部出现白色 Header 区域,左侧有灰色圆形头像占位。
5.3 实现搜索框(Row + layoutWeight)
在 Row 内、// ② 搜索框 注释处,填入以下代码:
// HomePage.etsexport struct HomePage { build() { Column() { Row({ space: 8 }) { Stack() { ... } // 头像圆(5.2 已完成) Row({ space: 8 }) { Text('🔍') .fontSize(13) .fontColor('#AAAAAA') Text('搜索感兴趣的内容') .fontSize(13) .fontColor('#AAAAAA') .layoutWeight(1) } .height(32) .padding({ left: 8, right: 8 }) .backgroundColor('#F2F2F2') .borderRadius(16) .layoutWeight(1) // ← 核心:撑满剩余空间,图标被推到右侧 // ③ 铃铛 + 私信图标 —— 5.4 步骤完成 } ... } }}💡 外层
Row上的.layoutWeight(1)是关键——它让整个搜索框组件占满头像和图标之间的所有剩余宽度。
✅ 预期效果:Header 中间出现灰底圆角搜索框。
5.4 添加铃铛与私信图标
在 Row 内、// ③ 铃铛 + 私信图标 注释处,填入:
// HomePage.etsexport struct HomePage { build() { Column() { Row({ space: 8 }) { ... // 头像圆 + 搜索桂5.2-5.3 已完成) Image($r('app.media.ic_bell')) .width(24) .height(24) Image($r('app.media.ic_message')) .width(24) .height(24) } ... } }}✅ 最终效果:Header 完整显示——头像圆 | 自适应搜索框 | 铃铛 | 私信,图标固定贴右边缘。
目标:在 Header 下方加入可横向滑动的分类 Tab 栏,点击后激活态变色。
6.1 新建 VideoListContent.ets 占位组件
内容区的具体内容后面再实现,先创建一个占位组件,让 Tab 页有内容可以渲染。
在 entry/src/main/ets/components/VideoListContent.ets 中写入:
// VideoListContent.ets(新建文件)@Componentexport struct VideoListContent { build() { Column() { Text('内容区 - 开发中') .fontSize(16) .fontColor('#CCCCCC') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .backgroundColor('#F4F5F7') }}6.2 在 HomePage.ets 中添加分类数据与状态
打开 entry/src/main/ets/components/HomePage.ets,在文件最顶部(@Component 之前)添加导入和分类数组:
// HomePage.ets — 文件顶部(@Component 之前)import { VideoListContent } from './VideoListContent'
const CATEGORIES: string[] = [ '直播', '推荐', '热门', '动画', '影视', '新征程', '国创', '番剧']
@Componentexport struct HomePage { ... }在 HomePage 结构体内、build() 之前添加状态变量:
// HomePage.ets@Componentexport struct HomePage { @State activeCategory: number = 1 ...}
activeCategory控制当前高亮的 Tab 索引,初始值1对应「推荐」(数组中第二项,索引从 0 开始)。
6.3 编写 @Builder categoryTab
在 activeCategory 状态变量下方,添加自定义 Tab 样式的 Builder 方法:
// HomePage.ets@Componentexport struct HomePage { @State activeCategory: number = 1
@Builder categoryTab(label: string, index: number) { Column({ space: 4 }) { Text(label) .fontSize(15) .fontColor(this.activeCategory === index ? '#FB7299' : '#333333') .fontWeight(this.activeCategory === index ? FontWeight.Bold : FontWeight.Normal) Divider() .strokeWidth(2) .color(this.activeCategory === index ? '#FB7299' : Color.Transparent) .width(16) } .padding({ left: 8, right: 8, top: 4, bottom: 4 }) }
build() { ... }}💡 通过三元表达式
this.activeCategory === index ? ... : ...,被选中的 Tab 变为粉色加粗,下方出现粉色短线;未选中的 Tab 短线颜色设为Transparent以保持高度一致。
6.4 用 Tabs 替换内容占位文字
在 build() 的 Column 内、Header Row 闭合之后,将之前的占位 Text('首页 - 开发中') 删除,替换为:
// HomePage.etsexport struct HomePage { build() { Column() { Row(...) { ... } // Header(5.2−5.4 已完成) Tabs({ index: this.activeCategory }) { ForEach(CATEGORIES, (cat: string, idx: number) => { TabContent() { VideoListContent() } .tabBar(this.categoryTab(cat, idx)) }) } .barMode(BarMode.Scrollable) // 超出屏幕宽度可横向滑动 .barHeight(40) .layoutWeight(1) // 占满 Header 以下的所有剩余高度 .barBackgroundColor(Color.White) .onChange((index: number) => { this.activeCategory = index // 点击 Tab 时同步更新激活状态 }) } ... }}✅ 预期效果:Header 下方出现白色 Tab 栏,包含「直播 / 推荐 / 热门 / …」,「推荐」默认高亮,点击可切换。
目标:在 VideoListContent.ets 中搭建可滚动的两列等宽布局,先用灰色占位块代替真实卡片。
7.1 理解数据分组逻辑
看看效果图里的内容区:视频卡片是一行两列的,页面向上滚动时,顶部的 Banner 轮播图和底下的视频卡片一起上移。
这两个特点决定了布局方案:
1. 用 List 作为统一滚动容器
List 里的每个 ListItem 都可以放任意内容,放入 Swiper 时天然占满 List 宽度,视频卡片行和 Banner 也就自然同步滚动:
List(统一滚动容器) ├── ListItem → Swiper(Banner 轮播图,后续步骤 9 加入) ├── ListItem → 第 1 行视频 ├── ListItem → 第 2 行视频 └── ...2. 每个 ListItem 放一”行”(两张卡片)
如果直接对 20 条视频做 ForEach,每条放一个独立的 ListItem,结果是每行只显示一张卡片(ListItem 默认撑满宽度)。
要实现两列,就要把数据提前分组:每 2 条视频打包成一个 VideoPairItem,ForEach 遍历这 10 对数据,每对在一个 ListItem 内用 Row 左右并排:
List 本身不支持多列网格布局(Grid 可以,但无法与 Banner 混排),因此需要手动把视频数组按每 2 条一行进行分组,让每个 ListItem 内部放一个 Row 来并排展示两张卡片:
① 原始数据:每条视频是独立的(共 20 条)
aboutToAppear() 里,每取 2 条放进同一个 VideoPairItem ② 分组后:每项包含左右两条(共 10 对)
ForEach 遍历,每一对 → 一个 ListItem ③ 渲染结果:每行并排两张卡片
7.2 添加数据属性与分组逻辑
打开 entry/src/main/ets/components/VideoListContent.ets,将文件顶部导入替换为:
// VideoListContent.ets — 文件顶部import { VideoPairItem, BILIBILI_VIDEO_LIST } from '../viewmodel/VideoData'在 VideoListContent 结构体内添加数组属性和 aboutToAppear 方法:
aboutToAppear()是 ArkUI 的生命周期函数,在组件首次渲染之前自动执行,适合在此处做数据预处理。
// VideoListContent.ets@Componentexport struct VideoListContent {
private pairList: VideoPairItem[] = []
aboutToAppear(): void { for (let i = 0; i < BILIBILI_VIDEO_LIST.length; i += 2) { const pair = new VideoPairItem() pair.pairId = i / 2 pair.left = BILIBILI_VIDEO_LIST[i] if (i + 1 < BILIBILI_VIDEO_LIST.length) { pair.right = BILIBILI_VIDEO_LIST[i + 1] pair.hasRight = true } this.pairList.push(pair) } }
build() { ... }}注意当视频总数为奇数时,最后一对只有左侧有数据(
hasRight为false),右侧需要保留空位维持布局对齐。
7.3 用 List + ForEach 渲染两列骨架
将 build() 方法替换为:
// VideoListContent.ets@Componentexport struct VideoListContent { build() { // 删除掉占位用的元素 Text('内容区 - 开发中') .fontSize(16) .fontColor('#CCCCCC')
// 添加List组件 List() { ForEach(this.pairList, (pair: VideoPairItem) => { ListItem() { Row({ space: 8 }) { // 左列占位块 Row() .layoutWeight(1) .height(120) .backgroundColor('#CCCCCC') .borderRadius(4)
// 右列:有数据时显示占位块,否则保留空位(保持等宽对齐) if (pair.hasRight) { Row() .layoutWeight(1) .height(120) .backgroundColor('#CCCCCC') .borderRadius(4) } else { Row().layoutWeight(1) } } .padding({ left: 8, right: 8, bottom: 8 }) } }, (pair: VideoPairItem) => pair.pairId.toString()) } .width('100%') .height('100%') .scrollBar(BarState.Off) .backgroundColor('#F4F5F7') }}💡 两列等宽的关键是左右两个
Row都加.layoutWeight(1)——它们以相同权重平分父容器的剩余宽度,无论屏幕多宽都自动适配。
✅ 预期效果:内容区出现 10 行两列等宽灰色占位块,可上下滚动。
目标:创建 VideoCard.ets 组件,逐步拼装封面、浮层、标题、作者行,最终替换占位灰色块。
8.1 分析 VideoCard 的布局结构
每张卡片从上到下由三个区域组成:
Column(白色圆角卡片)├── Stack(封面区,Alignment.Bottom)│ ├── Image(封面图,16:9 比例)│ └── Row(底部浮层:播放量 | 弹幕数 时长)│ └── linearGradient 渐变遮罩├── Column(标题区,高度固定 40,最多 2 行省略)│ └── Text└── Row(作者行) ├── Circle(4×4 粉色圆点) └── Text(作者名)💡 为什么 Stack 用
Alignment.Bottom?因为浮层信息要贴着封面图底边显示,Alignment.Bottom让子元素默认对齐底部。
8.2 创建文件骨架,实现封面图
新建 entry/src/main/ets/components/VideoCard.ets,写入以下文件骨架并实现封面图部分:
// VideoCard.ets(新建文件)import { VideoItem } from '../viewmodel/VideoData'
@Componentexport struct VideoCard { item: VideoItem = new VideoItem(0, '', '', '', '', '', '')
build() { Column() { Stack({ alignContent: Alignment.Bottom }) { // ① 封面图 Image(this.item.cover) .width('100%') .aspectRatio(1.78) // 保持 16:9 宽高比 .objectFit(ImageFit.Cover) .backgroundColor('#E8E8E8') // 图片加载前的灰色占位背景 // ② 浮层 Row —— 8.4 步骤完成 } .width('100%') // ③ 标题区 Column —— 8.5 步骤完成 // ④ 作者行 Row —— 8.6 步骤完成 } .backgroundColor(Color.White) .borderRadius(4) .clip(true) }}8.3 在 VideoListContent.ets 中接入 VideoCard
打开 entry/src/main/ets/components/VideoListContent.ets:
① 在文件顶部补充 VideoCard 的导入:
// VideoListContent.ets — 文件顶部import { VideoPairItem, BILIBILI_VIDEO_LIST } from '../viewmodel/VideoData'import { VideoCard } from './VideoCard'② 将 Row({ space: 8 }) 内两个灰色 Row() 占位块替换为 VideoCard:
替换前(删除灰色占位块):
// VideoListContent.etsexport struct VideoListContent { build() { ... Row({ space: 8 }) { Row() .layoutWeight(1) .height(120) .backgroundColor('#CCCCCC') .borderRadius(4) if (pair.hasRight) { Row() .layoutWeight(1) .height(120) .backgroundColor('#CCCCCC') .borderRadius(4) } else { Row().layoutWeight(1) } } ... }}替换后(使用 VideoCard 组件):
// VideoListContent.etsexport struct VideoListContent { build() { ... Row({ space: 8 }) { VideoCard({ item: pair.left }) .layoutWeight(1) if (pair.hasRight) { VideoCard({ item: pair.right }) .layoutWeight(1) } else { Row().layoutWeight(1) } } ... }}✅ 预期效果:两列灰色块变为白色圆角卡片,可以看到封面图。后续步骤将逐步完善浮层信息、标题和作者行。
8.4 回到VideoCard.ets组件给,封面图添加底部信息浮层
在 Stack 内、封面 Image 之后(// ② 浮层 Row 注释处),填入:
页面要显示的特殊符号为:▶,💬 ,可以复制粘贴到代码中
// VideoCard.etsexport struct VideoCard { build() { Column() { Stack({ alignContent: Alignment.Bottom }) { Image(...) { ... } // ① 封面图(8.2 已完成) // ② 浮层 Row Row() { Text('▶ ' + this.item.views) .fontSize(10) .fontColor(Color.White) Blank() // 弹性空白,将左侧播放量和右侧信息推向两端 Text('💬 ' + this.item.danmaku) .fontSize(10) .fontColor(Color.White) Text(' ' + this.item.duration) .fontSize(10) .fontColor(Color.White) } .width('100%') .padding({ left: 8, right: 8, top: 16, bottom: 4 }) .linearGradient({ // 从底部黑色半透明渐变到顶部全透明 direction: GradientDirection.Top, colors: [['#BB000000', 0.0], ['#00000000', 1.0]] }) } ... } ... }}💡
Blank()是弹性空白组件,会将左侧播放量与右侧弹幕/时长各自贴边显示。
✅ 预期效果:封面图底部出现半透明渐变遮罩,显示播放量、弹幕数、时长。
8.5 添加视频标题区域
在 Stack 闭合后(// ③ 标题区 Column 注释处),填入:
// VideoCard.etsexport struct VideoCard { build() { Column() { Stack(...) { ... } // 封面区(8.2~8.4 已完成) // ③ 标题区 Column Column() { Text(this.item.title) .fontSize(13) .fontColor('#212121') .maxLines(2) // 最多显示两行 .textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出时末尾显示省略号 .lineHeight(20) .width('100%') } .constraintSize({minHeight: 50}) // 设置最小高度:保证左右两列卡片的标题组件高度一致 .width('100%') .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.Start) .padding({ left: 8, right: 8, top: 8 }) // ④ 作者行 Row —— 8.6 步骤完成 } ... }}✅ 预期效果:封面图下方显示视频标题,且最多显示两行。
8.6 添加作者行
在标题 Column 闭合后(// ④ 作者行 Row 注释处),填入:
// VideoCard.etsexport struct VideoCard { build() { Column() { Stack(...) { ... } // 封面区(8.2~8.4 已完成) Column(...) { ... } // 标题区(8.5 已完成) // ④ 作者行 Row Row({ space: 4 }) { Circle() .width(4) .height(4) .fill('#FB7299') // B站标志性粉色圆点 Text(this.item.author) .fontSize(11) .fontColor('#999999') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .layoutWeight(1) } .padding({ left: 8, right: 8, top: 8, bottom: 8 }) .width('100%') } ... }}✅ 预期效果:卡片底部出现粉色小圆点 + 灰色作者名文字。
目标:在视频列表顶部加入自动轮播 Banner,每张图片底部叠加标题文字。
9.1 理解 Banner 的嵌套结构
Banner 放在 List 的第一个 ListItem 中,内部从外到内嵌套:
ListItem└── Swiper(自动轮播容器) └── ForEach(遍历 BANNER_COVERS) └── Stack(层叠布局,Alignment.BottomStart) ├── Image(Banner 图片,全宽 192 高) └── Text(标题浮层,底部对齐 + 渐变遮罩)9.2 更新顶部导入,引入 Banner 数据
打开 entry/src/main/ets/components/VideoListContent.ets,将顶部两行导入修改为:
// VideoListContent.ets — 文件顶部import { VideoPairItem, BILIBILI_VIDEO_LIST } from '../viewmodel/VideoData'import { VideoPairItem, BILIBILI_VIDEO_LIST, BANNER_COVERS, BANNER_TITLES } from '../viewmodel/VideoData'import { VideoCard } from './VideoCard'9.3 在 List 顶部插入 Banner(先不加文字浮层)
在 build() 的 List() 内,ForEach(this.pairList, ...) 之前插入以下 ListItem:
// VideoListContent.etsexport struct VideoListContent { build() { List() { ListItem() { Swiper() { ForEach(BANNER_COVERS, (url: string) => { Image(url) .width('100%') .height(192) .objectFit(ImageFit.Cover) }) } .autoPlay(true) .interval(3000) .loop(true) .height(192) .margin({ left: 8, right: 8, bottom: 8 }) } ForEach(this.pairList, ...) { ... } // 视频列表(7.3 已完成) } ... }}✅ 预期效果:列表顶部出现可自动切换的 Banner 图片(每 3 秒切换一张,显示默认的指示器,暂无圆角)。
9.4 给 Banner 图片添加文字浮层
在 Swiper 内的 ForEach 回调上做三处修改:① 添加 index 参数;② 用 Stack 层叠容器包裹 Image;③ 在 Image 下方添加文字浮层 Text。
// VideoListContent.etsexport struct VideoListContent { build() { Column() { List() { ListItem() { Swiper() { ForEach(BANNER_COVERS, (url: string) => { ForEach(BANNER_COVERS, (url: string, index: number) => { Stack({ alignContent: Alignment.BottomStart }) { Image(url) .width('100%') .height(192) .objectFit(ImageFit.Cover) Text('正在推荐 · ' + BANNER_TITLES[index]) .fontSize(13) .fontColor(Color.White) .fontWeight(FontWeight.Medium) .padding({ left: 8, right: 8, top: 16, bottom: 28 }) .width('100%') .linearGradient({ direction: GradientDirection.Top, colors: [['#CC000000', 0.0], ['#00000000', 1.0]] }) } .width('100%') }) } }
...
} } ... }}✅ 预期效果:banner图底部显示标题。
9.5 为 Swiper 添加指示器与圆角
在 9.3 写好的 Swiper 属性末尾(.height(192) 之后),补充以下属性:
// VideoListContent.etsexport struct VideoListContent { build() { List() { ListItem() { Swiper() { ... } // ForEach Banner(9.4 已完成) .autoPlay(true) .interval(3000) .loop(true) .height(192) .margin({ left: 8, right: 8, bottom: 8 }) .borderRadius(4) .clip(true) // 让图片圆角生效 .indicator(true) .indicatorStyle({ color: '#66FFFFFF', selectedColor: Color.White }) } ForEach(this.pairList, ...) { ... } // 视频列表 } ... }}最终 Swiper 的完整属性链:
.autoPlay(true)→.interval(3000)→.loop(true)→.height(192)→.borderRadius(4)→.clip(true)→.indicator(true)→.indicatorStyle(...)。
✅ 最终效果:页面顶部出现带渐变标题浮层的自动轮播 Banner,右下角显示白色圆点指示器;Banner 下方仍为两列可滚动的视频卡片。
拓展 1 — 下拉刷新
要求:在视频列表上实现下拉刷新效果,用户向下拖动列表时触发刷新动画,1.5 秒后自动恢复。
实现思路:
ArkUI 提供了 Refresh 组件,专门用于实现下拉刷新:
- 用
Refresh包裹原有的List,List就自动具备下拉触发刷新的能力 Refresh的refreshing属性控制是否显示刷新动画,需要用@State双向绑定- 当用户下拉松手时,
onRefreshing回调被触发——在这里模拟”请求完成”,延迟 1.5 秒后将isRefreshing改回false,刷新动画随之停止
整体结构为:
Refresh(refreshing: $$isRefreshing) └── List ├── ListItem(Banner) └── ListItem(视频对...)参考 ArkUI 官方文档:Refresh 组件
拓展 2 — 顶部消息通知红点
要求:给 Header 右侧的铃铛图标添加未读消息红点角标,点击铃铛后显示 Toast 提示并清除红点。
实现思路:
ArkUI 提供了 Badge 容器组件,可以在任何子组件的右上角显示数字角标或小红点:
- 用
Badge包裹原有的铃铛Image,通过count参数显示未读消息数(如初始值5) - 使用
@State声明一个状态变量管理未读数 - 给铃铛区域添加
onClick事件:点击后将未读数置为0,同时调用promptAction.showToast显示”暂无新消息”提示 - 当未读数为
0时,Badge会自动隐藏角标 - 别忘了在文件顶部从
@kit.ArkUI中导入promptAction
参考 ArkUI 官方文档:Badge 组件
提交内容
请提交以下文件和截图:
| 提交项 | 说明 |
|---|---|
entry/src/main/ets/pages/Index.ets | 已修改的入口文件 |
entry/src/main/ets/components/HomePage.ets | 首页组件 |
entry/src/main/ets/components/VideoListContent.ets | 内容列表组件 |
entry/src/main/ets/components/VideoCard.ets | 视频卡片组件 |
| 运行效果截图 | 至少 2 张:首页完整效果 + 导航切换效果;如已完成拓展任务,需附带对应截图 |
评分标准
- 顶部 Header 区域布局正确(Logo、搜索框、图标)
- 分类 Tab 栏可切换,激活态样式正确
- 视频卡片两列等宽布局,封面、浮层信息、标题、作者行完整
- Banner 轮播自动播放,文字浮层叠加正确
- 底部导航栏 5 项布局正确,切换有响应
- 代码结构清晰:组件拆分合理、数据与 UI 分离、命名规范
- 拓展(加分项):下拉刷新 / 通知红点,需附截图
截止时间
以雨课堂作业要求的截止时间为准。