适用读者:想在 ArkTS 里实现搜索过滤功能的开发者。本文从菜谱 App 的搜索框 UI 出发,分析"有 UI 无逻辑"的现状,给出完整的搜索实现方案,拆解 TextInput 的交互细节。
完整效果
一、当前搜索框的代码结构
// Index.ets@StatesearchText: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.searchTexttext 属性绑定 @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 里加过滤逻辑
@StatesearchText:string=''// 新增:过滤后的菜谱数组getFilteredRecipes():Recipe[]{if(this.searchText.trim().length===0)returnRECIPESconstkeyword=this.searchText.trim().toLowerCase()constresult:Recipe[]=[]for(leti:number=0;i<RECIPES.length;i++){if(RECIPES[i].name.toLowerCase().includes(keyword)||RECIPES[i].desc.toLowerCase().includes(keyword)){result.push(RECIPES[i])}}returnresult}build(){// ... 搜索框// 改动:ForEach 从 RECIPES 改为 getFilteredRecipes()ForEach(this.getFilteredRecipes(),(r:Recipe)=>{Row(){/* 菜谱卡片 */}})}方案分析:
| 优点 | 缺点 |
|---|---|
| 改动最小——只改 ForEach 的数据源 | 每次 build 都重新计算过滤结果 |
| 不需要新增 @State 变量 | 搜索关键词变化时整个 build 方法重新执行 |
toLowerCase()的作用——忽略大小写搜索。用户输入"红"和"红"(全角)都能匹配"红烧肉"。includes检查关键词是否出现在菜名或描述里。
trim().length === 0的判断——搜索框为空时显示全部菜谱。用户清空搜索框后,列表恢复为全部 8 道菜。
方案二:用 @State 存储过滤结果
@StatesearchText:string=''@StatefilteredRecipes:Recipe[]=RECIPES// 新增// 搜索框 onChange 改动.onChange((v:string)=>{this.searchText=vthis.updateFiltered()})privateupdateFiltered():void{if(this.searchText.trim().length===0){this.filteredRecipes=RECIPESreturn}constkeyword=this.searchText.trim().toLowerCase()constresult:Recipe[]=[]for(leti: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.filteredRecipesForEach(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) | “简单” 匹配所有简单菜谱 |
按食材搜索的实现
privateupdateFiltered():void{if(this.searchText.trim().length===0){this.filteredRecipes=RECIPESreturn}constkeyword=this.searchText.trim().toLowerCase()constresult:Recipe[]=[]for(leti:number=0;i<RECIPES.length;i++){constr:Recipe=RECIPES[i]// 匹配菜名if(r.name.toLowerCase().includes(keyword)){result.push(r);continue}// 匹配描述if(r.desc.toLowerCase().includes(keyword)){result.push(r);continue}// 匹配食材for(letj: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 也有分类筛选(当前没有,但如果加了)privateupdateFiltered():void{letresult:Recipe[]=RECIPES// 先按分类过滤if(this.selectedCat.length>0){result=getRecipesByCat(this.selectedCat)}// 再按搜索词过滤if(this.searchText.trim().length>0){constkeyword=this.searchText.trim().toLowerCase()constfiltered:Recipe[]=[]for(leti: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 道菜的规模下,搜索功能的实现成本很低。如果数据量增大,需要考虑防抖和索引优化。