鸿蒙原生应用实战(三):表单交互与搜索筛选------添加包裹、搜索过滤与公司管理
本文是系列第三篇,深入讲解快递追踪 App 中的三个交互型页面:添加包裹表单、搜索与筛选、快递公司管理。涵盖表单验证、选择器实现、多条件搜索、Toggle 开关等实战内容。
一、概述
本项目的交互型页面有三个,各有特点:
| 页面 | 核心交互 | 技术要点 |
|---|---|---|
| AddPackagePage | 文本输入 + 下拉选择 + 表单验证 | TextInput、自定义Picker、实时校验 |
| SearchPage | 文本搜索 + 标签筛选 + 结果列表 | 多条件组合查询、Tab 筛选 |
| CompanyManagePage | 搜索过滤 + Toggle 开关 + 收藏管理 | 计算属性、ToggleType.Switch |
二、添加包裹页面(AddPackagePage)
2.1 页面布局
┌─────────────────────────────┐
│ ← 添加包裹 │
├─────────────────────────────┤
│ 输入快递单号 │
│ ┌───────────────────────┐ │
│ │ 请输入快递单号 │ │
│ └───────────────────────┘ │
│ │
│ 快递公司 │
│ ┌───────────────────────┐ │
│ │ 顺丰速运 ▼ │ │
│ ├───────────────────────┤ │ ← 点击展开
│ │ 圆通速递 │ │
│ │ 中通快递 │ │
│ │ ... │ │
│ └───────────────────────┘ │
│ │
│ 备注 │
│ ┌───────────────────────┐ │
│ │ 选填 │ │
│ └───────────────────────┘ │
│ │
│ ┌───────────────────────┐ │
│ │ 保存 │ │
│ └───────────────────────┘ │
│ 单号格式正确 ✓ │ ← 实时校验提示
└─────────────────────────────┘
2.2 状态管理
typescript
@Entry
@Component
struct AddPackagePage {
@State trackingNo: string = ''; // 快递单号
@State selectedCompany: string = '顺丰速运'; // 选中的快递公司
@State note: string = ''; // 备注
@State showPicker: boolean = false; // 选择器展开/收起
}
2.3 自定义下拉选择器
鸿蒙没有原生的下拉选择器(Picker/Select),我们用 Text + 条件渲染实现一个:
typescript
// 快递公司列表
let courierCompanies: string[] = [
'顺丰速运', '圆通速递', '中通快递',
'韵达快递', '京东快递', '邮政EMS', '极兔速递'
];
// 选择器触发器
Row() {
Text(this.selectedCompany)
Blank()
Text('▼') // 下拉箭头
}
.onClick(() => {
this.showPicker = !this.showPicker; // 切换展开/收起
})
// 下拉列表(条件渲染)
if (this.showPicker) {
Column() {
ForEach(courierCompanies, (company: string) => {
Text(company)
.onClick(() => {
this.selectedCompany = company; // 选中
this.showPicker = false; // 收起
})
}, (company: string) => company)
}
}
设计要点:
- 选中项高亮显示:
fontColor(this.selectedCompany === company ? primary : text_primary) - 点击列表项后自动收起选择器
- 列表外部没有点击遮罩层------可以通过再次点击触发器收起
2.4 表单实时验证
typescript
// 保存按钮
Button($r('app.string.btn_save'))
.onClick(() => {
if (this.trackingNo.length === 0) {
return; // 空单号不处理
}
if (this.trackingNo.length < 6) {
return; // 单号太短
}
router.back(); // 验证通过,返回首页
})
// 实时提示文字
Row() {
if (this.trackingNo.length > 0 && this.trackingNo.length < 6) {
Text('单号长度不足,请检查')
.fontColor($r('app.color.status_exception')) // 红色警告
} else if (this.trackingNo.length === 0) {
Text('请输入快递单号')
.fontColor($r('app.color.text_hint')) // 灰色提示
} else {
Text('单号格式正确 ✓')
.fontColor($r('app.color.status_delivered')) // 绿色成功
}
}
三种状态提示:
| 状态 | 条件 | 颜色 | 文案 |
|---|---|---|---|
| 未输入 | length === 0 |
灰色 | 请输入快递单号 |
| 输入中但不足 | length > 0 && length < 6 |
红色 | 单号长度不足 |
| 输入完成 | length >= 6 |
绿色 | 单号格式正确 ✓ |
这是典型的正向反馈设计------用户每输入一个字符都能看到状态变化。
2.5 为什么不使用 TextArea 做备注?
当前备注使用了单行 TextInput,对于简短备注(如"手机""书籍")足够。如果需要长文本,应该替换为 TextArea:
typescript
// 如果需要多行备注
TextArea({ placeholder: '选填,支持多行', text: this.note })
.height(100)
三、搜索与筛选页面(SearchPage)
3.1 功能设计
搜索页支持两种筛选维度:
- 文本搜索:按快递单号、公司名、备注搜索
- 状态筛选:全部 / 运输中 / 已签收 / 异常
两种维度组合生效,即搜索结果同时匹配文本和状态。
3.2 数据结构
typescript
interface PackageResult {
id: number;
trackingNo: string;
company: string;
status: string; // transit | delivered | exception
statusText: string;
note: string;
updateTime: string;
}
3.3 多条件组合搜索
typescript
doSearch(): void {
this.hasSearched = true;
let query = this.searchQuery.toLowerCase().trim();
this.searchResults = [];
for (let pkg of this.allPackages) {
// 条件1:文本匹配(单号/公司/备注)
let matchQuery = query.length === 0 ||
pkg.trackingNo.toLowerCase().indexOf(query) >= 0 ||
pkg.company.indexOf(query) >= 0 ||
pkg.note.indexOf(query) >= 0;
// 条件2:状态筛选
let matchFilter = this.selectedFilter === 0 || // 全部
(this.selectedFilter === 1 && pkg.status === 'transit') ||
(this.selectedFilter === 2 && pkg.status === 'delivered') ||
(this.selectedFilter === 3 && pkg.status === 'exception');
// AND 组合
if (matchQuery && matchFilter) {
this.searchResults.push(pkg);
}
}
}
设计模式:
- 将"文本匹配"和"状态匹配"分开为两个布尔变量
- 用
&&组合,语义清晰 query.length === 0时不限制文本,即显示该状态筛选下的全部
3.4 标签筛选栏
typescript
private filterTabs: string[] = ['全部', '运输中', '已签收', '异常'];
Row() {
ForEach(this.filterTabs, (tab: string, index: number) => {
Text(tab)
.fontColor(this.selectedFilter === index ? Color.White : $r('app.color.text_primary'))
.backgroundColor(this.selectedFilter === index ? $r('app.color.primary') : $r('app.color.background'))
.borderRadius(16)
.onClick(() => { this.onFilterClick(index); })
}, (tab: string) => tab)
}
选中态与未选中态的视觉区分:
- 选中:白色文字 + 主题色背景
- 未选中:主题色文字 + 浅灰背景
3.5 搜索框与键盘交互
typescript
TextInput({ placeholder: '输入单号/快递公司/备注', text: this.searchQuery })
.onChange((value: string) => { this.searchQuery = value; })
.onSubmit(() => { this.doSearch(); }) // 键盘回车触发搜索
onSubmit 捕获键盘回车事件,提升移动端输入体验------用户输入完毕直接点回车即可搜索。
3.6 搜索状态的三种 UI
typescript
// 状态1:初始态(未搜索过)
if (!this.hasSearched) {
// 显示引导文案
}
// 状态2:搜索有结果
if (this.hasSearched && this.searchResults.length > 0) {
// 显示结果列表
}
// 状态3:搜索无结果
if (this.hasSearched && this.searchResults.length === 0) {
// 显示"没有找到"空状态
}
三种状态的切换必须处理好"闪烁"问题 ------hasSearched 初始为 false,用户首次点击搜索才置为 true。
四、快递公司管理页面(CompanyManagePage)
4.1 功能需求
- 展示所有支持的快递公司列表
- 可搜索过滤公司
- 通过 Toggle 开关标记常用公司
- 顶部显示已选数量统计
4.2 数据结构
typescript
interface CompanyItem {
name: string;
phone: string; // 客服电话
website: string; // 官网
isFavorite: boolean; // 是否收藏
}
4.3 计算属性(getter)
typescript
// 筛选后的公司列表
get filteredCompanies(): CompanyItem[] {
if (!this.companies) return [];
if (this.searchText.length === 0) return this.companies;
let q = this.searchText.toLowerCase();
let result: CompanyItem[] = [];
for (let c of this.companies) {
if (c.name.toLowerCase().indexOf(q) >= 0) {
result.push(c);
}
}
return result;
}
// 收藏数量
get favoriteCount(): number {
if (!this.companies) return 0;
let count = 0;
for (let c of this.companies) {
if (c.isFavorite) count++;
}
return count;
}
ArkTS 的计算属性(getter)会在每次渲染时重新求值,因此不需要额外声明 @State 来存储筛选结果,减少了状态同步的复杂度。
4.4 Toggle 开关组件
typescript
Toggle({ type: ToggleType.Switch, isOn: company.isFavorite })
.onChange(() => {
this.toggleFavorite(company.name);
})
Toggle 是鸿蒙原生开关组件,支持三种类型:
ToggleType.Switch:滑动开关(最常用)ToggleType.Checkbox:复选框ToggleType.Button:按钮式
⚠️ 注意:isOn 是初始值,但 Toggle 内部会维护自己的选中状态。当用户操作时,我们需要通过 toggleFavorite 同步修改 companies 数据。
typescript
toggleFavorite(name: string): void {
for (let i = 0; i < this.companies.length; i++) {
if (this.companies[i].name === name) {
this.companies[i].isFavorite = !this.companies[i].isFavorite;
break;
}
}
}
由于 companies 是用 @State 装饰的数组,通过索引修改元素属性不会 触发 UI 更新------但这里我们只是修改 isFavorite 布尔值,Toggle 组件自身维护了视觉状态,所以 UI 不会出现不同步。
如果需要确保 UI 同步,应该创建新数组:
typescript
toggleFavorite(name: string): void {
this.companies = this.companies.map(c =>
c.name === name ? { ...c, isFavorite: !c.isFavorite } : c
);
}
4.5 头像圆圈设计
typescript
Stack() {
Circle()
.width(44).height(44)
.fill($r('app.color.primary'))
Text(company.name.substring(0, 1)) // 取公司名的第一个字
.fontSize(20)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
使用 Stack 叠放圆形背景和首字,形成类似微信头像的风格。取公司名第一个字(顺→S、圆→Y、中→Z),简单有效。
五、表单验证的最佳实践
5.1 验证时机
| 验证时机 | 实现方式 | 适用场景 |
|---|---|---|
| 实时验证 | onChange + 状态提示 | 单号长度、格式 |
| 提交时验证 | onClick 统一校验 | 必填项检查 |
| 失焦验证 | onBlur | 输入完成后检查 |
5.2 用户反馈层次
好的表单反馈应该有层次感:
🔴 单号长度不足,请检查 → 错误(阻止提交)
⚪ 请输入快递单号 → 提示(允许提交,但提交会失败)
🟢 单号格式正确 ✓ → 成功(可提交)
六、交互设计要点总结
6.1 添加包裹页
showPicker控制下拉展开/收起,点外侧不会自动关闭(简化实现)- 实时校验三个状态:未输入/错误/正确
- 保存按钮校验后
router.back()返回首页(实际项目应传递数据给首页)
6.2 搜索页
- 文本 + 状态双维度筛选,组合使用
- 三种 UI 状态:未搜索 / 有结果 / 无结果
- 搜索框支持回车提交
6.3 公司管理页
- 搜索即时过滤(onChange 触发)
- Toggle 开关管理收藏状态
- 顶部显示统计 "已选 N/M"
- 公司名首字 + 圆形背景构成头像

七、小结
本篇完成了三个交互密集型页面的开发:
- ✅ AddPackagePage:表单输入 + 自定义选择器 + 实时验证
- ✅ SearchPage:多条件搜索 + 标签筛选 + 三种状态 UI
- ✅ CompanyManagePage:搜索过滤 + Toggle 开关 + 计算属性
核心知识点:
- 条件渲染实现下拉选择器
- 搜索的逻辑组合与性能
- @State 数组修改的限制与正确方式
- 表单验证的层次反馈设计
下一篇将进入 物流时间线与历史记录,详解时间线 UI 组件的绘制、router 参数传递与接收、列表统计等高级内容。
系列索引:
- 第一篇:项目初始化与工程架构
- 第二篇:首页与列表开发实战
- 第三篇:表单交互与搜索筛选(本文)
- 第四篇:物流时间线与历史记录
- 第五篇:数据统计与个人中心