适用读者: 想在 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.searchTextthis.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 道菜的规模下,搜索功能的实现成本很低。如果数据量增大,需要考虑防抖和索引优化。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐