HarmonyOS菜谱——搜索功能的实现方案与TextInput交互
适用读者: 想在 ArkTS 里实现搜索过滤功能的开发者。本文从菜谱 App 的搜索框 UI 出发,分析"有 UI 无逻辑"的现状,给出完整的搜索实现方案,拆解 TextInput 的交互细节。
完整效果
一、当前搜索框的代码结构
// Index.ets
@State searchText: string = ''
Row() {
SymbolGlyph($r('sys.symbol.magnifyingglass')).fontSize(16).fontColor([T3])
.margin({ left: 12 })
TextInput({ placeholder: '搜索菜谱...', text: this.searchText })
.layoutWeight(1).height(40).backgroundColor('transparent').fontSize(14)
.onChange((v: string) => { this.searchText = v })
}
.width('100%').height(40).backgroundColor('#F0EEF4').borderRadius(20)
.padding({ left: 16, right: 16 }).margin({ left: 16, right: 16, bottom: 16 })

搜索框的 UI 已经完成——放大镜图标 + 输入框,圆角灰色背景。但 onChange 只是把输入内容同步到 @State searchText,没有实际的过滤逻辑。用户输入"红烧"后,下方的菜谱列表仍然显示全部 8 道菜,不会筛选出"红烧肉"。
二、TextInput 的三个关键属性
placeholder:占位提示文字
TextInput({ placeholder: '搜索菜谱...', text: this.searchText })
placeholder 在输入框为空时显示,用户开始输入后消失。 它告诉用户"这里可以输入什么"——"搜索菜谱…"比"请输入"更具体,用户一看就知道搜索的目标是菜谱。
踩坑记录: 最初 placeholder 写了"请输入关键词",用户反馈不知道搜什么。改成"搜索菜谱…"后,用户明确知道搜索范围是菜谱名称和描述。
text:双向绑定的输入内容
text: this.searchText
text 属性绑定 @State 变量——实现双向绑定。 用户输入时,onChange 把新值赋给 this.searchText;this.searchText 变化时,TextInput 的显示内容自动更新。
为什么需要双向绑定? 搜索场景下,输入框需要实时反映当前搜索词——用户可能中途修改搜索词,修改后列表需要重新过滤。双向绑定保证输入框内容和过滤逻辑用的是同一个值。
onChange:实时监听输入变化
.onChange((v: string) => { this.searchText = v })
onChange 在每次输入变化时触发——用户每打一个字都会触发。 参数 v 是当前输入框的完整内容(不是增量)。
onChange vs onSubmit 的区别:
| 事件 | 触发时机 | 适用场景 |
|---|---|---|
| onChange | 每次输入变化 | 实时搜索过滤 |
| onSubmit | 点击键盘确认键 | 提交搜索(跳转搜索结果页) |
菜谱 App 的搜索框需要实时过滤——用户打一个字就筛选一次,不需要点确认。所以用 onChange 而不是 onSubmit。
为什么不用 backgroundColor 而是外层 Row 提供背景
TextInput({ ... }).backgroundColor('transparent')
TextInput 背景透明,外层 Row 提供灰色背景。 如果给 TextInput 加背景色,会和 Row 的背景叠加——出现两层颜色。透明背景让 TextInput "融入"外层 Row,视觉上是一个整体。
三、搜索过滤的完整实现方案
方案一:在 Index 里加过滤逻辑
@State searchText: string = ''
// 新增:过滤后的菜谱数组
getFilteredRecipes(): Recipe[] {
if (this.searchText.trim().length === 0) return RECIPES
const keyword = this.searchText.trim().toLowerCase()
const result: Recipe[] = []
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].name.toLowerCase().includes(keyword) ||
RECIPES[i].desc.toLowerCase().includes(keyword)) {
result.push(RECIPES[i])
}
}
return result
}
build() {
// ... 搜索框
// 改动:ForEach 从 RECIPES 改为 getFilteredRecipes()
ForEach(this.getFilteredRecipes(), (r: Recipe) => {
Row() { /* 菜谱卡片 */ }
})
}
方案分析:
| 优点 | 缺点 |
|---|---|
| 改动最小——只改 ForEach 的数据源 | 每次 build 都重新计算过滤结果 |
| 不需要新增 @State 变量 | 搜索关键词变化时整个 build 方法重新执行 |
toLowerCase() 的作用——忽略大小写搜索。 用户输入"红"和"红"(全角)都能匹配"红烧肉"。includes 检查关键词是否出现在菜名或描述里。
trim().length === 0 的判断——搜索框为空时显示全部菜谱。 用户清空搜索框后,列表恢复为全部 8 道菜。
方案二:用 @State 存储过滤结果
@State searchText: string = ''
@State filteredRecipes: Recipe[] = RECIPES // 新增
// 搜索框 onChange 改动
.onChange((v: string) => {
this.searchText = v
this.updateFiltered()
})
private updateFiltered(): void {
if (this.searchText.trim().length === 0) {
this.filteredRecipes = RECIPES
return
}
const keyword = this.searchText.trim().toLowerCase()
const result: Recipe[] = []
for (let i: number = 0; i < RECIPES.length; i++) {
if (RECIPES[i].name.toLowerCase().includes(keyword) ||
RECIPES[i].desc.toLowerCase().includes(keyword)) {
result.push(RECIPES[i])
}
}
this.filteredRecipes = result
}
build() {
// ForEach 改为 this.filteredRecipes
ForEach(this.filteredRecipes, (r: Recipe) => { ... })
}
方案分析:
| 优点 | 缺点 |
|---|---|
| 过滤结果缓存在 @State 里 | 需要新增一个 @State 变量 |
| build 方法不需要每次重新计算 | 需要手动调用 updateFiltered() |
方案二比方案一更优——过滤结果只在搜索词变化时计算,不是每次 build 都计算。 当其他 @State 变化(比如收藏按钮切换)导致 build 重新执行时,方案一会重新过滤(即使搜索词没变),方案二不会。
方案对比
| 维度 | 方案一 | 方案二 |
|---|---|---|
| 代码改动量 | 小(改 ForEach 数据源) | 中(加 @State + 方法) |
| 性能 | 每次 build 重新计算 | 只在搜索词变化时计算 |
| 适用场景 | 数据量小(< 50 条) | 数据量大(> 50 条) |
| 推荐度 | 原型阶段可用 | 生产环境推荐 |
菜谱 App 只有 8 道菜,方案一完全够用。但如果菜谱增加到几百道,方案二的性能优势就体现出来了。
四、搜索匹配的扩展维度
当前方案只匹配菜名和描述。可以扩展的匹配维度:
| 匹配维度 | 实现方式 | 匹配例子 |
|---|---|---|
| 菜名 | r.name.includes(keyword) |
“红烧” 匹配 “红烧肉” |
| 描述 | r.desc.includes(keyword) |
“酸辣” 匹配 “酸辣脆爽” |
| 分类 | r.category.includes(keyword) |
“烘焙” 匹配 “戚风蛋糕”(category=烘焙) |
| 食材 | 遍历 r.ingredients |
“鸡蛋” 匹配 “番茄鸡蛋面” 和 “戚风蛋糕” |
| 难度 | r.difficulty.includes(keyword) |
“简单” 匹配所有简单菜谱 |
按食材搜索的实现
private updateFiltered(): void {
if (this.searchText.trim().length === 0) {
this.filteredRecipes = RECIPES
return
}
const keyword = this.searchText.trim().toLowerCase()
const result: Recipe[] = []
for (let i: number = 0; i < RECIPES.length; i++) {
const r: Recipe = RECIPES[i]
// 匹配菜名
if (r.name.toLowerCase().includes(keyword)) { result.push(r); continue }
// 匹配描述
if (r.desc.toLowerCase().includes(keyword)) { result.push(r); continue }
// 匹配食材
for (let j: number = 0; j < r.ingredients.length; j++) {
if (r.ingredients[j].name.toLowerCase().includes(keyword)) { result.push(r); break }
}
}
this.filteredRecipes = result
}
continue 的作用——匹配成功后跳过后续检查。 菜名匹配了就不需要再检查描述和食材,减少不必要的遍历。
踩坑记录: 最初没有 continue,一道菜可能被重复加入结果数组(菜名匹配一次、描述又匹配一次)。加了 continue 后每道菜最多只被 push 一次。
搜索"鸡蛋"的结果示例
| 菜谱 | 匹配原因 | 匹配字段 |
|---|---|---|
| 番茄鸡蛋面 | 食材含"鸡蛋" | ingredients[2].name |
| 戚风蛋糕 | 食材含"鸡蛋" | ingredients[1].name |
用户输入"鸡蛋"后,列表只显示这两道菜——因为只有它们的食材里有鸡蛋。这种按食材搜索的体验比只搜菜名更实用——用户可能记得"想用鸡蛋做菜"但不记得具体菜名。
五、搜索无结果的空状态
当搜索词没有匹配结果时,当前显示空白区域。需要加空状态提示:
if (this.filteredRecipes.length === 0) {
Column() {
Text('🔍').fontSize(48).opacity(0.2).margin({ bottom: 12 })
Text('没有找到相关菜谱').fontSize(14).fontColor(T2)
Text('试试其他关键词').fontSize(12).fontColor(T3).margin({ top: 4 })
}.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center)
}
空状态文案"试试其他关键词"比"暂无数据"更有帮助。 前者暗示"换个词可能找到",后者暗示"这里什么都没有"。
踩坑记录: 最初空状态加在了 this.recipes.length === 0 的判断里(分类页的空状态逻辑),但 Index 的搜索空状态需要单独判断 this.filteredRecipes.length === 0。两个空状态的触发条件不同——分类页是"该分类没有菜谱",搜索是"没有匹配的菜谱"。
六、搜索与分类的联动
当前搜索和分类是独立的——搜索框输入"红烧"会过滤全部 8 道菜,不会限制在当前分类内。如果需要"在当前分类内搜索",需要叠加两个过滤条件:
// 假设 Index 也有分类筛选(当前没有,但如果加了)
private updateFiltered(): void {
let result: Recipe[] = RECIPES
// 先按分类过滤
if (this.selectedCat.length > 0) {
result = getRecipesByCat(this.selectedCat)
}
// 再按搜索词过滤
if (this.searchText.trim().length > 0) {
const keyword = this.searchText.trim().toLowerCase()
const filtered: Recipe[] = []
for (let i: number = 0; i < result.length; i++) {
if (result[i].name.toLowerCase().includes(keyword) ||
result[i].desc.toLowerCase().includes(keyword)) {
filtered.push(result[i])
}
}
result = filtered
}
this.filteredRecipes = result
}
先分类后搜索的顺序——缩小范围再精确匹配。 先按分类过滤出子集(比如"家常菜"3 道),再在子集里搜索关键词。如果反过来先搜索再分类,搜索范围是全部 8 道菜,效率更低。
当前 Index 没有分类筛选功能(分类是跳转到 CategoryPage),所以不需要叠加过滤。但如果以后 Index 加了分类 Tab,这个叠加逻辑就需要了。
七、搜索性能的优化方向
当前的 O(n) 遍历
每次搜索词变化都遍历全部 8 道菜——对每道菜检查 name 和 desc 是否包含关键词。在 8 道菜的规模下性能完全不是问题。
数据量增大后的优化方案
| 优化方案 | 原理 | 适用场景 |
|---|---|---|
| 防抖(debounce) | 用户停止输入 300ms 后才执行搜索 | 输入速度快、数据量大 |
| 索引 | 预处理菜谱数据,建关键词→菜谱的映射 | 数据量 > 100 条 |
| 分页 | 搜索结果分页加载 | 结果 > 20 条 |
防抖的实现思路: 用户每打一个字不立即搜索,而是等 300ms 没有新输入后才执行。避免"红烧肉"三个字触发三次搜索(“红"→"红烧"→"红烧肉”)。
当前不需要优化——8 道菜的搜索耗时可以忽略。 过早优化是万恶之源。只有当用户反馈"搜索卡顿"时才需要考虑优化。
八、TextInput 的其他交互事件
onSubmit:键盘确认键
TextInput({ ... }).onSubmit(() => {
// 用户点击键盘的"搜索"/"确认"键时触发
// 适合跳转到搜索结果页
})
onSubmit 适合"提交式搜索"——用户输入完后点确认,跳转到搜索结果页。 菜谱 App 用的是"实时过滤式搜索",不需要 onSubmit。但如果搜索结果需要展示更多信息(比如搜索结果页有筛选、排序),onSubmit + 跳转更合适。
onFocus / onBlur:焦点获取和失去
TextInput({ ... })
.onFocus(() => { /* 输入框获得焦点,键盘弹出 */ })
.onBlur(() => { /* 输入框失去焦点,键盘收起 */ })
onFocus 适合"展开搜索模式"——用户点击搜索框时,页面切换到搜索模式(隐藏其他内容,只显示搜索结果)。 菜谱 App 没有这个需求,但很多电商/内容 App 有。
三个事件的选择场景
| 事件 | 触发时机 | 适用场景 |
|---|---|---|
| onChange | 每次输入变化 | 实时过滤(菜谱 App 用这个) |
| onSubmit | 点击键盘确认 | 提交搜索(跳转结果页) |
| onFocus | 点击输入框 | 展开搜索模式(隐藏其他内容) |
九、完整搜索功能的代码清单
如果要把菜谱 App 的搜索功能从"有 UI 无逻辑"升级为"完整可用",需要改动以下位置:
| 文件 | 改动 | 说明 |
|---|---|---|
| Index.ets | 加 @State filteredRecipes |
存储过滤结果 |
| Index.ets | 加 updateFiltered() 方法 |
过滤逻辑 |
| Index.ets | onChange 里调 updateFiltered() |
搜索词变化时重新过滤 |
| Index.ets | ForEach 改为 this.filteredRecipes |
渲染过滤结果 |
| Index.ets | 加搜索空状态 if-else | 无结果时提示 |
| RecipeData.ets | 可选:加 searchRecipes(keyword) |
把过滤逻辑抽到数据层 |
改动量评估: 约 30 行代码。在 8 道菜的规模下,搜索功能的实现成本很低。如果数据量增大,需要考虑防抖和索引优化。
更多推荐

所有评论(0)