跳转到内容

编程练习:仿写京东手机搜索结果页

使用本章所学的 HarmonyOS ArkUI 布局与样式复用能力,仿写京东 APP 手机类目商品搜索结果页的 UI 效果。

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

最终效果包含以下区域:

┌──────────────────────────────────────────┐
│ ← 🔍 手机 搜索 │ ← 搜索栏
├──────────────────────────────────────────┤
│ 综合 销量 价格 ↕ 筛选 ▼ │ ← 排序 Tab(点击切换,含红色下划线)
├──────────────────────────────────────────┤
│ [全部] [5G手机] [新品] [百亿补贴] ... │ ← 筛选标签(可横向滚动)
├──────────────────────────────────────────┤
│ 品牌推荐 更多 › │ ← 品牌推荐区(可横向滚动)
│ 🍎苹果 华为 小米 荣耀 vivo ... │
├──────────────────────────────────────────┤
│ ┌──────────────────────────────────┐ │
│ │ [手机图] vivo X300 12GB+256GB │ │ ← 商品卡片(可纵向滚动)
│ │ 自营 国家补贴 │ │
│ │ ¥2399 已售3.2万 [+] │ │
│ │ 🏪 vivo官方旗舰店 │ │
│ └──────────────────────────────────┘ │
│ ───────────────────────────────────── │
│ ┌──────────────────────────────────┐ │
│ │ [手机图] Apple/苹果 iPhone 17... │ │
│ │... │ │
│ └──────────────────────────────────┘ │
│ ...(共6条,可滚动) │
└──────────────────────────────────────────┘

考察知识点

知识点在本练习中的使用场景
Column / Row页面整体纵向布局、卡片内部横向布局
TextInput顶部搜索框
Image + objectFit商品图片展示与裁剪模式
Text + maxLines + textOverflow商品名称超出两行自动省略
@Extend(Text)统一标签文字的颜色、边框、背景等专属样式
@Styles统一商品卡片的宽度、内边距等公共属性
@Builder + 参数将商品卡片、Tab 项、标签等抽象为可复用构建函数
stateStyles卡片按压时背景色变灰,松开恢复白色
@State管理当前选中的 Tab 项和筛选标签,触发视图自动刷新
ForEach根据数组数据批量渲染 Tab、标签、品牌、商品列表
Scroll筛选标签/品牌区横向滚动,商品列表纵向滚动
layoutWeight让商品列表区域自动填充剩余页面高度
数据与 UI 分离接口定义和静态数据独立在 SearchData.ets 文件中管理

任务要求

  1. 完整实现效果图中所有区域的 UI,包括:搜索栏、排序 Tab、筛选标签行、品牌推荐区、商品卡片列表
  2. 必须使用 @Builder 将商品卡片抽象为可复用函数,不允许直接在 build() 中重复编写卡片结构
  3. 必须使用 ForEach 渲染商品列表,不允许手动调用多次 @Builder(即每种列表项只调用一次 ForEach)
  4. 商品列表必须可独立滚动,搜索栏和 Tab 栏固定在顶部(使用 Scroll + layoutWeight
  5. 排序 Tab 的选中状态必须响应点击(使用 @State + onClick
  6. 卡片需有按压态效果(stateStyles

2.1 了解模板工程内容

📦 模板工程仓库https://cnb.cool/sziit-coding/harmony-coding/SearchJD

克隆仓库后使用 DevEco Studio 打开 SearchJD/ 文件夹

本练习提供了一个已初始化好的 HarmonyOS 模板工程。模板中已预置以下内容,无需自行创建或导入

图片资源(位于 entry/src/main/resources/base/media/):

文件名说明
jd_product_1.jpgvivo X300 手机商品图
jd_product_2.jpgvivo X300 Pro 手机商品图
jd_product_3.jpgiPhone 17 白色商品图
jd_product_4.jpgREDMI Turbo 4 商品图
jd_product_5.jpgvivo X300 Pro 蓝色商品图
jd_product_6.jpgiPhone 17 薰衣草紫色商品图
ic_search.svg搜索图标
ic_arrow_left.svg返回箭头图标
ic_filter.svg筛选图标

基础代码骨架(已写入 entry/src/main/ets/pages/SearchPage.ets):

@Entry
@Component
struct SearchPage {
build() {
Column() {
// 后续各模块将依次添加到这里
}
.width('100%')
.height('100%')
.backgroundColor('#F4F4F4')
}
}

后续所有步骤均在此骨架基础上添加代码,不需要重新创建文件或修改根容器结构。

2.2 导入 DevEco Studio

  1. 打开 DevEco Studio
  2. 菜单选择 File → Open,选择模板工程的 SearchJD/ 文件夹
  3. 等待 Gradle 和 ohpm 依赖安装完成(首次约 2~5 分钟)
  4. 安装完成后,在左侧项目树中找到并打开:
    entry/src/main/ets/pages/SearchPage.ets
  5. 点击右上角 Previewer 图标,打开实时预览面板
  6. 确认 Previewer 中已出现一个浅灰色(#F4F4F4)全屏背景,说明模板骨架正常运行
初始运行效果预览

在正式开始开发之前,了解本练习中两个新用到的容器组件。

ForEach — 数据驱动列表渲染

ForEach 是 ArkUI 中根据数组动态渲染 UI 的标准方式,避免手动重复调用 @Builder

语法:

ForEach(
array, // 要遍历的数组
(item, index) => { // 每一项的 UI 构建函数
Text(item)
},
(item, index) => index.toString() // key 生成函数(用于高效 diff,建议提供)
)

示例: 渲染一组标签按钮

private tabs: string[] = ['全部', '5G手机', '新品', '百亿补贴']
build() {
Row() {
ForEach(this.tabs, (label: string, idx: number) => {
Text(label)
.fontSize(13)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(14)
.backgroundColor(idx === 0 ? '#E4393C' : '#F4F4F4')
.fontColor(idx === 0 ? Color.White : '#555555')
.margin({ right: 8 })
}, (_: string, idx: number) => idx.toString())
}
}

关键点:

  • 若数组数据变化(@State 修饰),UI 会自动刷新
  • key 函数返回值必须唯一,推荐用 idx.toString()item.id

Scroll — 独立可滚动容器

Scroll 包裹内容后,内容超出容器范围时可滚动查看,且不影响外部布局

语法:

Scroll() {
// 被滚动的内容(只能有一个直接子组件)
Column() {
// ...列表内容
}
}
.scrollable(ScrollDirection.Vertical) // 垂直滚动(默认)
.scrollBar(BarState.Off) // 隐藏滚动条
.layoutWeight(1) // 占满剩余高度(常用于列表区域)

水平滚动:

Scroll() {
Row() {
// ...横向内容
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

关键点:

  • Scroll 只能有一个直接子组件(通常是 ColumnRow
  • 配合 .layoutWeight(1) 可让滚动区域自动填满剩余空间,搜索栏固定不动

建议开发顺序:每完成一个步骤,先在 Previewer 中查看效果,确认正确后再进入下一步。

目标:了解模板已提供的 SearchPage 根容器结构,并确认 Previewer 预览正常。

打开 SearchPage.ets,可以看到模板已提供以下骨架代码:

@Entry
@Component
struct SearchPage {
build() {
Column() {
// 后续各模块将依次添加到这里
}
.width('100%')
.height('100%')
.backgroundColor('#F4F4F4')
}
}

分析骨架结构:

代码说明
@Entry标记该组件为页面入口,应用启动时渲染此页面
@Component标记为自定义组件,内部可包含 build() 方法
Column()根容器,纵向排列所有子模块(搜索栏、Tab、商品列表等)
.width('100%').height('100%')撑满屏幕
.backgroundColor('#F4F4F4')浅灰色页面背景(商品列表区域的底色)

后续所有步骤均在此 Column() { } 内部依次添加各功能模块的代码。

✅ 确认效果:Previewer 中应显示浅灰色(#F4F4F4)全屏背景。若不正确,请检查第一步是否导入成功。

顶部搜索栏由三部分横向排列:返回箭头、搜索输入区(图标 + 输入框)、搜索按钮。

4.1 外层结构

// 在 Column() { ... } 内添加:
Row({ space: 10 }) {
// 后续子组件填充
}
.width('100%')
.height(54)
.backgroundColor(Color.White)
.padding({ left: 14, right: 14 })
.alignItems(VerticalAlign.Center)

4.2 填入三个子元素

将上方 Row 中的注释替换为:

// ① 返回箭头
Image($r('app.media.ic_arrow_left'))
.width(22)
.height(22)
// ② 搜索输入区(灰色圆角背景 + 搜索图标 + TextInput)
Row({ space: 6 }) {
Image($r('app.media.ic_search'))
.width(15)
.height(15)
TextInput({ placeholder: '手机' })
.layoutWeight(1)
.height(34)
.backgroundColor(Color.Transparent)
.fontSize(13)
.placeholderColor('#9CA3AF')
.fontColor('#1A1A1A')
.caretColor('#E4393C')
}
.layoutWeight(1)
.height(34)
.backgroundColor('#F4F4F4')
.borderRadius(17)
.padding({ left: 10, right: 10 })
.alignItems(VerticalAlign.Center)
// ③ 搜索文字按钮
Text('搜索')
.fontSize(14)
.fontColor('#E4393C')
.fontWeight(FontWeight.Medium)

在搜索栏下方加一条分割线:

Divider().strokeWidth(0.5).color('#EEEEEE')

✅ 预期效果:顶部出现白色搜索栏,含返回箭头、灰色圆角输入框(内有搜索图标)、红色「搜索」文字。

完成搜索栏效果

6.1 排序Tab UI效果

排序 Tab UI 效果

6.2 定义数据和状态

首先在文件顶部导入所需的类型和常量:

import { SortTabData, SORT_TABS } from '../model/SearchData'

然后在 SearchPage 结构体内(build() 方法之前)添加:

// 当前选中的排序项(0=综合 1=销量 2=价格 3=筛选)
@State selectedSort: number = 0
// Tab 数据(字段 label/showArrows 已在模型中定义)
private sortTabs: SortTabData[] = SORT_TABS

SortTabData 包含两个字段:label: string(标签文字)、showArrows: boolean(是否显示上下箭头,价格项为 true)。

6.3 用 @Builder 抽取单个 Tab

代码中价格排序使用字符 ▲ 和 ▼ 模拟箭头,筛选项使用预置的漏斗图标。

@Builder
SortTab(label: string, idx: number, showArrows: boolean) {
Column({ space: 3 }) {
Row({ space: 3 }) {
Text(label)
.fontSize(13)
.fontColor(this.selectedSort === idx ? '#E4393C' : '#333333')
.fontWeight(this.selectedSort === idx ? FontWeight.Bold : FontWeight.Normal)
// 价格项:显示上下箭头
if (showArrows) {
Column({ space: 0 }) {
Text('▲')
.fontSize(7)
.fontColor(this.selectedSort === idx ? '#E4393C' : '#CCCCCC')
.lineHeight(10)
Text('▼')
.fontSize(7)
.fontColor(this.selectedSort === idx ? '#E4393C' : '#CCCCCC')
.lineHeight(10)
}
}
// 筛选项:显示漏斗图标
if (label === '筛选') {
Image($r('app.media.ic_filter'))
.width(12)
.height(12)
}
}
.alignItems(VerticalAlign.Center)
// 选中态红色下划线
Divider()
.strokeWidth(2)
.color(this.selectedSort === idx ? '#E4393C' : Color.Transparent)
.width(32)
}
.height(42)
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.selectedSort = idx
})
}

Builder 参数说明:相比之前的 label/idx 两个参数,新增了 showArrows 来指示是否显示价格箭头,这样 Builder 内部可直接通过该标志判断,无需硬编码索引。

6.4 用 ForEach 渲染 Tab 栏

// 在 build() 中,分割线之后添加:
Row() {
ForEach(this.sortTabs, (item: SortTabData, idx: number) => {
this.SortTab(item.label, idx, item.showArrows)
}, (_: SortTabData, idx: number) => idx.toString())
}
.width('100%')
.height(42)
.backgroundColor(Color.White)
Divider().strokeWidth(0.5).color('#EEEEEE')

✅ 预期效果:出现「综合 销量 价格 筛选」四个等宽 Tab,点击时对应项文字变红、底部出现红色下划线指示器。

完成排序 Tab 效果

在排序 Tab 下方添加一行可横向滚动的快捷筛选标签,支持点击切换选中态。

快捷筛选标签行效果

7.1 添加数据和 @Builder

将文件顶部的 import 更新为:

import { SortTabData, SORT_TABS, FILTER_CHIPS } from '../model/SearchData'

在结构体内(build() 之前)添加数据和 Builder:

@State filterIndex: number = 0
private filterChips: string[] = FILTER_CHIPS
@Builder
FilterChip(label: string, idx: number) {
Text(label)
.fontSize(12)
.fontColor(this.filterIndex === idx ? Color.White : '#555555')
.backgroundColor(this.filterIndex === idx ? '#E4393C' : '#F4F4F4')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.margin({ right: 8 })
.onClick(() => { this.filterIndex = idx })
}

build() 的 Tab 栏分割线之后,添加水平滚动筛选行:

Scroll() {
Row() {
ForEach(this.filterChips, (chip: string, idx: number) => {
this.FilterChip(chip, idx)
}, (_: string, idx: number) => idx.toString())
}
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(42)
.backgroundColor(Color.White)
Divider().strokeWidth(0.5).color('#F0F0F0')

✅ 预期效果:排序 Tab 下方出现一行圆角标签,默认「全部」红底白字选中,点击其他标签可切换,整行可横向滑动。

完成筛选标签行效果
品牌推荐区效果

在筛选标签下方添加品牌推荐横向滚动区,展示各手机品牌 logo(首字母圆底)+ 品牌名称。

8.1 添加数据和 @Builder

将文件顶部 import 更新为:

import { SortTabData, BrandData, SORT_TABS, FILTER_CHIPS, BRANDS } from '../model/SearchData'

在结构体内(build() 之前)添加:

// 品牌数据(字段 name/bgColor 已在模型中定义)
private brands: BrandData[] = BRANDS
@Builder
BrandItem(name: string, bgColor: string) {
Column({ space: 4 }) {
Text(name.charAt(0))
.fontSize(16).fontColor(Color.White)
.width(44).height(44).borderRadius(22)
.backgroundColor(bgColor)
.textAlign(TextAlign.Center)
.fontWeight(FontWeight.Bold)
Text(name).fontSize(10).fontColor('#333333')
}
.alignItems(HorizontalAlign.Center)
.margin({ right: 16 })
}

build()函数的筛选行分割线之后,加入品牌区:

Column() {
Row() {
Text('品牌推荐').fontSize(12).fontColor('#1A1A1A').fontWeight(FontWeight.Bold)
Blank()
Text('更多 ›').fontSize(11).fontColor('#9CA3AF')
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 6 })
Scroll() {
Row() {
ForEach(this.brands, (brand: BrandData) => {
this.BrandItem(brand.name, brand.bgColor)
}, (brand: BrandData) => brand.name)
}
.padding({ left: 12, right: 12, bottom: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.backgroundColor(Color.White)
Divider().strokeWidth(0.5).color('#F0F0F0')

✅ 预期效果:品牌推荐区出现彩色圆形 logo,各品牌名称显示在圆形下方,整行可横向滑动。

完成品牌推荐区效果

9.1 商品数据结构分析

观察京东商品卡片的UI设计,了解每个数据字段的作用:

商品卡片UI结构与数据字段对应关系

ProductData 接口定义:

export interface ProductData {
imageRes: Resource
name: string
price: string
sales: string
tag1: string
tag2: string
shop: string
}

数据字段说明:

数据字段UI对应元素数据类型说明
imageRes商品图片Resource类型,支持本地图片资源
name商品名称string类型,需要处理超长文本省略
price价格显示string类型(如”2399”),便于格式化显示
sales销量信息string类型(如”3.2万”),已格式化完成
tag1轮廓标签string类型,对应@Extend轮廓样式
tag2填充标签string类型,对应@Extend填充样式
shop店铺名称string类型,可为空字符串(不显示店铺)

9.2 导入数据模型

import { ProductData, PRODUCTS } from '../model/SearchData'
// 在SearchPage结构体内声明
private products: ProductData[] = PRODUCTS

此时IDE应无报错,this.products 准备就绪,下一步可直接使用。

10.1 搭建基础架子

商品卡片UI效果

创建 ProductCard @Builder,先用注释标明组件位置:

@Builder
ProductCard(product: ProductData) {
Column({ space: 0 }) {
Row() {
// ① 左:商品图片(116x116)
// 这里将放置商品图片 Image 组件
// ② 右:商品信息区域
Column({ space: 8 }) {
// ②-1 商品名称(最多2行,超出省略)
// 这里将放置商品标题 Text 组件
// ②-2 标签行(轮廓标签 + 填充标签)
Row({ space: 4 }) {
// 这里将放置 product.tag1 和 product.tag2 标签
}
// ②-3 价格(¥ + 数字)
Row() {
// 这里将放置价格显示
}
.alignItems(VerticalAlign.Bottom)
// ②-4 底部信息(销量 + 包邮 + 加购按钮)
Row() {
// 这里将放置销量、包邮文字和加购按钮
Blank()
}
.width('100%').alignItems(VerticalAlign.Center)
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
.layoutWeight(1).height(116).margin({ left: 10 })
}
.width('100%').alignItems(VerticalAlign.Top)
// ③ 店铺名称(可选显示)
// 这里将根据 product.shop 参数条件显示店铺信息
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor(Color.White)
}

10.2 集成到主页面

build() 中品牌区分割线之后添加:

Scroll() {
Column() {
ForEach(this.products, (item: ProductData, idx: number) => {
this.ProductCard(item)
// 商品间分割线
if (idx < this.products.length - 1) {
Divider()
.strokeWidth(0.5).color('#F0F0F0')
.margin({ left: 12, right: 12 })
}
}, (_: ProductData, idx: number) => idx.toString())
}
.width('100%').backgroundColor(Color.White)
}
.layoutWeight(1)
.scrollBar(BarState.Auto)

运行查看效果:页面显示完整布局,商品区域有占位卡片,可正常滚动。

10.3 实现商品图片和名称

替换注释①和②-1,实现商品图片和名称:

// ① 左:商品图片(替换注释)
Image(product.imageRes)
.width(116).height(116)
.objectFit(ImageFit.Cover)
.borderRadius(4)
.backgroundColor('#F5F5F5')
.flexShrink(0)
// ②-1 商品名称(替换注释)
Text(product.name)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(18)

运行程序,页面效果如下:

实现图片和名称效果

10.4 实现标签和价格

SearchPage j结构体外,定义两种标签样式的 @Extend,分别为 tagOutline(红色描边)和 tagFill(红色填充)。

// 以下代码写在 sturct SearchPage 之外,作为全局样式扩展
// 红色描边标签(如"自营")
@Extend(Text)
function tagOutline() {
.fontSize(10)
.fontColor('#E4393C')
.borderWidth(0.5)
.borderColor('#E4393C')
.borderRadius(2)
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
}
// 红色填充标签(如"百亿补贴")
@Extend(Text)
function tagFill() {
.fontSize(10)
.fontColor('#E4393C')
.backgroundColor('#FFF2F2')
.borderRadius(2)
.padding({ left: 3, right: 3, top: 1, bottom: 1 })
}

替换注释②-2和②-3,实现标签和价格:

// ②-2 标签行(替换注释)
Row({ space: 4 }) {
if (product.tag1 !== '') { Text(product.tag1).tagOutline() }
if (product.tag2 !== '') { Text(product.tag2).tagFill() }
}
// ②-3 价格(替换注释)
Row() {
Text('¥')
.fontSize(12).fontColor('#E4393C').fontWeight(FontWeight.Bold).margin({ bottom: 1 })
Text(product.price)
.fontSize(22).fontColor('#E4393C').fontWeight(FontWeight.Bold)
}
.alignItems(VerticalAlign.Bottom)

运行程序,页面效果如下:

实现标签和价格效果

10.5 实现底部信息

替换注释②-4,实现销量、包邮和加购按钮:

// ②-4 底部信息(替换注释)
Row() {
Text('已售 ' + product.sales).fontSize(11).fontColor('#9CA3AF')
Text(' 包邮').fontSize(11).fontColor('#FF6000')
Blank()
Text('+')
.fontSize(17).fontColor(Color.White)
.width(26).height(26).borderRadius(13)
.backgroundColor('#E4393C')
.textAlign(TextAlign.Center).fontWeight(FontWeight.Bold)
}
.width('100%').alignItems(VerticalAlign.Center)

运行程序,页面效果如下:

实现底部信息效果

10.6 添加店铺名称和交互效果

替换注释③,添加店铺名称和交互状态:

店铺名称前面的符号:🏪

// ③ 店铺名称(替换注释)
if (product.shop !== '') {
Row({ space: 4 }) {
Text('🏪').fontSize(11)
Text(product.shop).fontSize(11).fontColor('#9CA3AF')
}
.margin({ top: 6 })
}

运行查看最终效果:完整的商品卡片列表。

完成商品卡片效果

11.1 作业评分标准(总分100分)

评分维度权重评判要点
功能实现55%UI布局完整、商品卡片正确显示、交互功能正常
代码质量25%代码规范、组件复用合理、数据处理正确
技术掌握15%布局和样式技术应用得当
作业规范5%按时提交、文件完整、截图清晰

11.2 提交要求

必须提交:

  1. SearchPage.ets - 完整的商品搜索页源代码文件
  2. 运行截图 - 清晰显示商品列表区域的效果图

截止时间: 以雨课堂公布的时间为准

提交方式: 通过雨课堂系统提交

💡 提示:建议在提交前使用Previewer预览效果,确保功能正常运行后再提交。