编程练习:仿写京东手机搜索结果页
使用本章所学的 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 文件中管理 |
任务要求
- 完整实现效果图中所有区域的 UI,包括:搜索栏、排序 Tab、筛选标签行、品牌推荐区、商品卡片列表
- 必须使用
@Builder将商品卡片抽象为可复用函数,不允许直接在build()中重复编写卡片结构 - 必须使用
ForEach渲染商品列表,不允许手动调用多次@Builder(即每种列表项只调用一次 ForEach) - 商品列表必须可独立滚动,搜索栏和 Tab 栏固定在顶部(使用
Scroll+layoutWeight) - 排序 Tab 的选中状态必须响应点击(使用
@State+onClick) - 卡片需有按压态效果(
stateStyles)
1.1 了解模板工程内容
📦 模板工程仓库:https://cnb.cool/sziit-coding/harmony-coding/SearchJD
克隆仓库后使用 DevEco Studio 打开
SearchJD/文件夹
本练习提供了一个已初始化好的 HarmonyOS 模板工程。模板中已预置以下内容,无需自行创建或导入:
图片资源(位于 entry/src/main/resources/base/media/):
| 文件名 | 说明 |
|---|---|
jd_product_1.jpg | vivo X300 手机商品图 |
jd_product_2.jpg | vivo X300 Pro 手机商品图 |
jd_product_3.jpg | iPhone 17 白色商品图 |
jd_product_4.jpg | REDMI Turbo 4 商品图 |
jd_product_5.jpg | vivo X300 Pro 蓝色商品图 |
jd_product_6.jpg | iPhone 17 薰衣草紫色商品图 |
ic_search.svg | 搜索图标 |
ic_arrow_left.svg | 返回箭头图标 |
ic_filter.svg | 筛选图标 |
基础代码骨架(已写入 entry/src/main/ets/pages/SearchPage.ets):
@Entry@Componentstruct SearchPage {
build() { Column() { // 后续各模块将依次添加到这里 } .width('100%') .height('100%') .backgroundColor('#F4F4F4') }}后续所有步骤均在此骨架基础上添加代码,不需要重新创建文件或修改根容器结构。
1.2 导入 DevEco Studio
- 打开 DevEco Studio
- 菜单选择 File → Open,选择模板工程的
SearchJD/文件夹 - 等待 Gradle 和 ohpm 依赖安装完成(首次约 2~5 分钟)
- 安装完成后,在左侧项目树中找到并打开:
entry/src/main/ets/pages/SearchPage.ets
- 点击右上角 Previewer 图标,打开实时预览面板
- 确认 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只能有一个直接子组件(通常是Column或Row)- 配合
.layoutWeight(1)可让滚动区域自动填满剩余空间,搜索栏固定不动
建议开发顺序:每完成一个步骤,先在 Previewer 中查看效果,确认正确后再进入下一步。
目标:了解模板已提供的 SearchPage 根容器结构,并确认 Previewer 预览正常。
打开 SearchPage.ets,可以看到模板已提供以下骨架代码:
@Entry@Componentstruct 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
@BuilderSortTab(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 = 0private filterChips: string[] = FILTER_CHIPS
@BuilderFilterChip(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
@BuilderBrandItem(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/目录中已预置的图片文件,无需手动添加图片资源。
理解数据结构:
| 字段 | 类型 | 说明 |
|---|---|---|
imageRes | Resource | 商品图片资源引用(使用 $r() 引用 media 目录) |
name | string | 商品完整名称(超长时在卡片中自动省略) |
price | string | 价格数字字符串(不含¥符号,由 UI 层拼接) |
sales | string | 销量文字(如 '3.2万'、'9600') |
tag1 / tag2 | string | 标签文字,空字符串表示不显示 |
shop | string | 店铺名称 |
定义标签样式 @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() 之前)添加:
@StylescardBase() { .width('100%') .padding({ left: 12, right: 12, top: 12, bottom: 12 })}构建单个商品卡片 @Builder
@BuilderProductCard( 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 张,需能清晰看到商品列表区域 |
⏰ 截止时间以课程通知为准。