跳转到内容

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

使用本章所学的 HarmonyOS ArkUI 布局与样式复用能力,仿写京东 APP 手机类目商品搜索结果页的 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

1.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')
}
}

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

1.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)全屏背景。若不正确,请检查第一步是否导入成功。

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

外层结构

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

填入三个子元素

将上方 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')

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

定义数据和状态

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

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)。

@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 内部可直接通过该标志判断,无需硬编码索引。

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 下方添加一行可横向滚动的快捷筛选标签,支持点击切换选中态。

添加数据和 @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(首字母圆底)+ 品牌名称。

添加数据和 @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 })
}

在筛选行分割线之后,加入品牌区:

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,各品牌名称显示在圆形下方,整行可横向滑动。

完成品牌区之后,紧接着实现商品部分:先了解数据模型,再定义样式和 Builder,最后挂载到列表中。

了解模板提供的数据模型文件

模板工程已预置 entry/src/main/ets/model/SearchData.ets,内容如下:

// ── 接口定义 ──────────────────────────────────────────────
export interface ProductData {
imageRes: Resource
name: string
price: string
sales: string
tag1: string
tag2: string
shop: string
}
// ── 商品静态数据 ──────────────────────────────────────────
export const PRODUCTS: ProductData[] = [
{
imageRes: $r('app.media.jd_product_1'), // 对应模板中已预置的图片资源
name: 'vivo X300 12GB+256GB 幸运彩 国家补贴 蔡司2亿超级主摄 APO超级长焦 OriginOS6 AI手机',
price: '2399', sales: '3.2万', tag1: '自营', tag2: '国家补贴', shop: 'vivo官方旗舰店'
},
{
imageRes: $r('app.media.jd_product_3'),
name: 'Apple/苹果 iPhone 17 256GB 白色 支持移动联通电信5G 双卡双待手机',
price: '5999', sales: '1.8万', tag1: '自营', tag2: '以旧换新', shop: 'Apple官方旗舰店'
},
{
imageRes: $r('app.media.jd_product_4'),
name: '小米 REDMI Turbo 4 天玑8400-Ultra IP68防水 12GB+256GB 祥云白 红米5G手机',
price: '1799', sales: '2.6万', tag1: '自营', tag2: '国家补贴', shop: '小米官方旗舰店'
},
{
imageRes: $r('app.media.jd_product_6'),
name: 'Apple/苹果 iPhone 17 256GB 薰衣草紫色 支持移动联通电信5G 双卡双待手机',
price: '5999', sales: '9600', tag1: '自营', tag2: '', shop: 'Apple官方旗舰店'
},
{
imageRes: $r('app.media.jd_product_2'),
name: 'vivo X300 Pro 蔡司2亿APO超级长焦 蓝图影像双芯 12GB+512GB OriginOS6 直屏拍照手机',
price: '3999', sales: '1.5万', tag1: '自营', tag2: '赠配件', shop: 'vivo官方旗舰店'
},
{
imageRes: $r('app.media.jd_product_5'),
name: 'vivo X300 Pro 卫星通信版 12GB+256GB 自在蓝 蔡司2亿超级主摄 蓝海电池 AI手机',
price: '4299', sales: '8800', tag1: '', tag2: '直降200', shop: 'vivo官方旗舰店'
},
]

注意$r('app.media.jd_product_1') 等图片引用均对应模板 media/ 目录中已预置的图片文件,无需手动添加图片资源。

理解数据结构:

字段类型说明
imageResResource商品图片资源引用(使用 $r() 引用 media 目录)
namestring商品完整名称(超长时在卡片中自动省略)
pricestring价格数字字符串(不含¥符号,由 UI 层拼接)
salesstring销量文字(如 '3.2万''9600'
tag1 / tag2string标签文字,空字符串表示不显示
shopstring店铺名称

定义标签样式 @Extend

SearchPage 结构体外部(文件顶部,@Entry 之前)添加两个全局样式扩展:

// 红色描边标签(如"自营")
@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 })
}

定义卡片公共样式 @Styles

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

@Styles
cardBase() {
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
}

构建单个商品卡片 @Builder

@Builder
ProductCard(
imageRes: Resource,
name: string,
price: string,
sales: string,
tag1: string,
tag2: string,
shop: string
) {
Column({ space: 0 }) {
Row() {
// 左:商品图片
Image(imageRes)
.width(116)
.height(116)
.objectFit(ImageFit.Cover)
.borderRadius(4)
.backgroundColor('#F5F5F5')
.flexShrink(0)
// 右:商品信息
Column({ space: 5 }) {
// 商品名称(最多2行,超出省略)
Text(name)
.fontSize(13)
.fontColor('#1A1A1A')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(18)
// 标签行
Row({ space: 4 }) {
if (tag1 !== '') { Text(tag1).tagOutline() }
if (tag2 !== '') { Text(tag2).tagFill() }
}
// 价格
Row() {
Text('¥')
.fontSize(12).fontColor('#E4393C').fontWeight(FontWeight.Bold).margin({ bottom: 1 })
Text(price)
.fontSize(22).fontColor('#E4393C').fontWeight(FontWeight.Bold)
}
.alignItems(VerticalAlign.Bottom)
// 底部:销量 · 包邮 · 加购按钮
Row() {
Text('已售 ' + 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)
}
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween)
.layoutWeight(1)
.height(116)
.margin({ left: 10 })
}
.width('100%')
.alignItems(VerticalAlign.Top)
// 店铺名称(可选显示)
if (shop !== '') {
Row({ space: 4 }) {
Text('🏪').fontSize(11)
Text(shop).fontSize(11).fontColor('#9CA3AF')
}
.margin({ top: 6 })
}
}
// 公共样式 + 按压态
.cardBase()
.stateStyles({
normal: { .backgroundColor(Color.White) },
pressed: { .backgroundColor('#F5F5F5') }
})
}

完整导入并声明商品数据字段

将文件顶部的 import 语句整合为完整导入(替换之前各步骤中逐步添加的 import):

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

SearchPage 结构体内(build() 之前)添加商品数据字段声明:

private products: ProductData[] = PRODUCTS

✅ 预期效果:IDE 无报错,this.products 准备就绪,下一步可直接用 ForEach 渲染商品列表。

ForEach 数据驱动方式渲染完整商品列表,并用 Scroll 包裹,实现独立可滚动的商品列表区域。

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

Scroll() {
Column() {
ForEach(this.products, (item: ProductData, idx: number) => {
this.ProductCard(
item.imageRes, item.name, item.price,
item.sales, item.tag1, item.tag2, item.shop
)
// 商品间分割线(最后一项不加)
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)
.backgroundColor('#F4F4F4')

✅ 预期效果:6张真实京东手机商品卡片全部展示,商品列表区域可独立上下滚动,顶部搜索栏、Tab 栏、筛选行和品牌区固定不动。

知识点覆盖检查清单

完成本练习后,请确认以下知识点已全部运用:

  • Column / Row:页面整体布局 + 卡片内部水平布局
  • TextInput:搜索框实现,自定义 placeholder 颜色和光标颜色
  • Image + objectFit:商品图片显示与缩放模式
  • Text + maxLines + textOverflow:商品名称多行省略
  • @Extend(Text):抽取 Text 组件专属属性(字体颜色、边框、背景等)
  • @Styles:抽取组件公共属性(宽度、内边距、圆角等)
  • @Builder + 参数传递:将商品卡片、Tab 项、筛选标签等抽象为可复用函数
  • stateStyles:卡片按压态背景色变化(normal / pressed)
  • @State:管理 Tab 选中态和筛选标签选中态,触发视图自动更新
  • ForEach:基于数组数据批量渲染 Tab、标签、品牌、商品列表
  • Scroll:水平滚动(筛选标签、品牌区)+ 垂直滚动(商品列表)
  • layoutWeight:让滚动列表区域自动填充页面剩余高度
  • 数据与 UI 分离:使用独立的 SearchData.ets 模型文件管理数据

提交规范

请提交以下两项内容:

提交项说明
SearchPage.ets商品搜索页的完整源代码文件
运行效果截图在 Previewer 或真机上的截图,至少 1 张,需能清晰看到商品列表区域

⏰ 截止时间以课程通知为准。