跳转到内容

编程实验:仿写鸿蒙端 B站首页

使用 HarmonyOS ArkUI 组件与布局能力,仿写 B站 APP 首页的 UI 效果。

在新页面打开
查看 UI 标注文档 完整的页面 UI 标注与元素尺寸说明,点击在新页面中打开

最终效果包含以下区域:

┌──────────────────────────────────────────┐
│ [TV] 搜索感兴趣的内容 铃 信 │ ← A 顶部 Header
├──────────────────────────────────────────┤
│ 直播 推荐 热门 动画 影视 更多 ... │ ← B 分类 Tab 栏(可横向滑动)
├──────────────────────────────────────────┤
│ ┌────────────────────────────────────┐ │
│ │ │ │
│ │ 正在推荐 · T1选手的中文名字 │ │ ← C Banner 轮播(自动播放)
│ │ │ │
│ └────────────────────────────────────┘ │
├──────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ │
│ │ [封面图] │ │ [封面图] │ │
│ │ ▶播放量 时│ │ ▶播放量 时│ │ ← D 两列视频卡片(可纵向滚动)
│ │ 视频标题 │ │ 视频标题 │ │
│ │ • 作者名 │ │ • 作者名 │ │
│ └──────────┘ └──────────┘ │
├──────────────────────────────────────────┤
│ 首页 关注 [+] 会员购 我的 │ ← E 底部导航栏
└──────────────────────────────────────────┘

考察知识点

知识点在本练习中的使用场景
Column / Row页面整体纵向布局、Header 横向排布、导航栏横向排布
@Component + 文件拆分VideoCardVideoListContentHomePage 抽取到独立文件
Tabs + TabContent首页分类 Tab 栏(直播 / 推荐 / 热门 …)
@Builder + 参数自定义 Tab 标签样式、底部导航项
@State + onChange / onClickTab 激活态颜色切换、底部导航页面切换
List + ListItem视频卡片列表可纵向滚动
ForEach批量渲染视频对、Tab 标签、Banner 图片
SwiperBanner 轮播图(autoPlay / interval / loop)
Stack + Alignment封面图上叠加浮层信息
linearGradient封面浮层半透明渐变遮罩
aspectRatio封面图保持 16:9 宽高比
layoutWeight两列卡片等宽分配、内容区填满剩余高度
maxLines + textOverflow视频标题超出两行时自动省略
数据与 UI 分离数据模型与常量统一在 VideoData.ets 中管理

任务要求

  1. 完整实现效果图中所有区域的 UI,包括:顶部 Header、分类 Tab 栏、Banner 轮播、两列视频卡片、底部导航栏
  2. 必须将各功能区域拆分为独立的 .ets 文件,不允许全部写在 Index.ets
  3. 必须使用 ForEach 渲染视频列表,不允许手动重复编写多张卡片
  4. Banner 必须实现自动轮播(使用 Swiper + autoPlay
  5. 分类 Tab 的激活态必须响应点击(使用 @State + onChange
  6. 底部导航栏点击必须切换页面(使用 @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_LIST20 条视频数据
BANNER_COVERS3 张 Banner 图片 URL
BANNER_TITLES3 条 Banner 标题

无需修改 VideoData.ets,直接导入使用即可。

入口骨架(位于 entry/src/main/ets/pages/Index.ets

模板已提供最基础的入口骨架:

@Entry
@Component
struct 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 确认初始运行效果

  1. 打开 DevEco Studio,导入模板工程
  2. 等待依赖安装完成
  3. 点击右上角 Previewer 图标,打开实时预览面板
  4. 确认页面显示 #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 样式:

@Builder
myTab(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 中创建首页组件的初始占位:

@Component
export 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
@Component
struct Index {
...
}

Index 结构体内添加状态变量:

@Entry
@Component
struct Index {
@State activeTab: number = 0
...
}

activeTab 记录当前选中的导航标签(0 = 首页,1 = 关注,3 = 会员购,4 = 我的),点击时同步更新。


4.3 在文件末尾添加 PlaceholderPage

build() 中的内容区需要用到 PlaceholderPage,先在 Index.ets 文件末尾Index 结构体外部)把它写好:

// Index.ets — 文件末尾(Index 结构体闭合 } 之后)
@Component
struct 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')
}
}

注意:PlaceholderPagestruct(不加 export),只在 Index.ets 文件内部使用。


4.4 实现 build() —— 内容区与页面切换

Indexbuild() 方法中,先写上方内容区(上半部分):

// Index.ets
@Entry
@Component
struct 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
@Component
struct 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.ets
struct 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.etsbuild() 中实现「头像占位圆 + 搜索框 + 铃铛 + 私信」横排 Header。


5.1 分析 Header 的布局结构

首页 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
@Component
export 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 区域,左侧有灰色圆形头像占位。

Header 初始实现效果预览

5.3 实现搜索框(Row + layoutWeight)

Row 内、// ② 搜索框 注释处,填入以下代码:

// HomePage.ets
export 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.ets
export 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 完整实现效果预览

目标:在 Header 下方加入可横向滑动的分类 Tab 栏,点击后激活态变色。

分类 Tab 栏设计图

6.1 新建 VideoListContent.ets 占位组件

内容区的具体内容后面再实现,先创建一个占位组件,让 Tab 页有内容可以渲染。

entry/src/main/ets/components/VideoListContent.ets 中写入:

// VideoListContent.ets(新建文件)
@Component
export 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[] = [
'直播', '推荐', '热门', '动画', '影视', '新征程', '国创', '番剧'
]
@Component
export struct HomePage { ... }

HomePage 结构体内、build() 之前添加状态变量:

// HomePage.ets
@Component
export struct HomePage {
@State activeCategory: number = 1
...
}

activeCategory 控制当前高亮的 Tab 索引,初始值 1 对应「推荐」(数组中第二项,索引从 0 开始)。


6.3 编写 @Builder categoryTab

activeCategory 状态变量下方,添加自定义 Tab 样式的 Builder 方法:

// HomePage.ets
@Component
export 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.ets
export 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 栏,包含「直播 / 推荐 / 热门 / …」,「推荐」默认高亮,点击可切换。

分类 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 条视频打包成一个 VideoPairItemForEach 遍历这 10 对数据,每对在一个 ListItem 内用 Row 左右并排:

List 本身不支持多列网格布局(Grid 可以,但无法与 Banner 混排),因此需要手动把视频数组按每 2 条一行进行分组,让每个 ListItem 内部放一个 Row 来并排展示两张卡片:

① 原始数据:每条视频是独立的(共 20 条)

V1 V2 V3 V4 V5 V6 …(共 20 条)
↓  在 aboutToAppear() 里,每取 2 条放进同一个 VideoPairItem

② 分组后:每项包含左右两条(共 10 对)

第 1 对 V1 V2
第 2 对 V3 V4
第 3 对 V5 V6
共 10 对
↓  ForEach 遍历,每一对 → 一个 ListItem

③ 渲染结果:每行并排两张卡片

V1 卡片
V2 卡片
V3 卡片
V4 卡片
V5 卡片
V6 卡片
…(共 10 行)

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
@Component
export 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() { ... }
}

注意当视频总数为奇数时,最后一对只有左侧有数据(hasRightfalse),右侧需要保留空位维持布局对齐。


7.3 用 List + ForEach 渲染两列骨架

build() 方法替换为:

// VideoListContent.ets
@Component
export 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'
@Component
export 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.ets
export 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.ets
export 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)
}
}
...
}
}

预期效果:两列灰色块变为白色圆角卡片,可以看到封面图。后续步骤将逐步完善浮层信息、标题和作者行。

封面图接入 VideoCard 后效果预览

8.4 回到VideoCard.ets组件给,封面图添加底部信息浮层

Stack 内、封面 Image 之后(// ② 浮层 Row 注释处),填入:

页面要显示的特殊符号为:▶,💬 ,可以复制粘贴到代码中

// VideoCard.ets
export 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.ets
export 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.ets
export 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 设计图

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.ets
export 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 秒切换一张,显示默认的指示器,暂无圆角)。

Banner 图片轮播实现效果预览

9.4 给 Banner 图片添加文字浮层

Swiper 内的 ForEach 回调上做三处修改:① 添加 index 参数;② 用 Stack 层叠容器包裹 Image;③ 在 Image 下方添加文字浮层 Text

// VideoListContent.ets
export 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图底部显示标题。

Banner 图片加文字浮层实现效果预览

9.5 为 Swiper 添加指示器与圆角

在 9.3 写好的 Swiper 属性末尾(.height(192) 之后),补充以下属性:

// VideoListContent.ets
export 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 下方仍为两列可滚动的视频卡片。

Banner 最终实现效果预览

拓展 1 — 下拉刷新

要求:在视频列表上实现下拉刷新效果,用户向下拖动列表时触发刷新动画,1.5 秒后自动恢复。


实现思路

ArkUI 提供了 Refresh 组件,专门用于实现下拉刷新:

  • Refresh 包裹原有的 ListList 就自动具备下拉触发刷新的能力
  • Refreshrefreshing 属性控制是否显示刷新动画,需要用 @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 分离、命名规范
  • 拓展(加分项):下拉刷新 / 通知红点,需附截图

截止时间

以雨课堂作业要求的截止时间为准。