HarmonyOS7 搜索页最容易做成半成品:历史、热词、结果页这次一次补齐
文章目录
- 前言
- 搜索栏组件
- 输入联想与防抖搜索
- 搜索历史:PersistentStorage 持久化
- 搜索落地页:历史 + 热词
- 搜索结果页:商品列表 + 筛选排序
- 空状态处理
- 搜索执行的入口
- 几点实用建议
前言
搜索是电商 App 的核心入口之一。用户明确知道自己要什么的时候,搜索比一层层翻分类快得多。这篇我们把搜索模块做完整:搜索栏交互、历史记录、热词推荐、搜索结果页,外加筛选排序和空状态处理。
搜索栏组件
搜索栏要支持两个场景:首页顶部的简洁版(点击跳转到搜索页),和搜索页顶部的完整版(输入框 + 取消按钮)。我把它们做成了同一个组件,通过参数区分模式。
// entry/src/main/ets/components/SearchBar.ets@Componentexportstruct SearchBar{@Propplaceholder:string='搜索商品'@PropisFocusMode:boolean=false@StateinputValue:string=''onSearch?:(keyword:string)=>voidonCancel?:()=>voidonFocus?:()=>voidonInputChange?:(value:string)=>voidbuild(){Row({space:10}){// 搜索输入框Row(){Image($r('app.media.ic_search')).width(18).height(18).margin({left:12})TextInput({text:this.inputValue,placeholder:this.placeholder}).layoutWeight(1).height(36).backgroundColor(Color.Transparent).fontSize(14).placeholderColor('#BBBBBB').onChange((value:string)=>{this.inputValue=valuethis.onInputChange?.(value)}).onSubmit(()=>{if(this.inputValue.trim().length>0){this.onSearch?.(this.inputValue.trim())}})// 有输入内容时显示清除按钮if(this.inputValue.length>0){Image($r('app.media.ic_clear')).width(18).height(18).margin({right:8}).onClick(()=>{this.inputValue=''this.onInputChange?.('')})}}.layoutWeight(1).height(36).backgroundColor('#F5F5F5').borderRadius(18)// 聚焦模式下显示取消按钮if(this.isFocusMode){Text('取消').fontSize(15).fontColor('#666666').onClick(()=>{this.onCancel?.()})}}.width('100%').height(56).padding({left:12,right:12}).backgroundColor(Color.White)}}首页的搜索栏是非聚焦模式,点击整个区域跳转到搜索页。搜索页用聚焦模式,输入框自动弹起键盘。
输入联想与防抖搜索
用户边输入边出联想词,体验很好,但直接每次输入都请求太浪费。加个防抖,停顿 300ms 再发请求:
// 在搜索页中使用防抖@Componentstruct SearchPage{@Statekeyword:string=''@Statesuggestions:string[]=[]privatedebounceTimer:number=-1build(){Column(){SearchBar({isFocusMode:true,onInputChange:(value:string)=>{this.keyword=valuethis.debounceSearch(value)},onSearch:(kw:string)=>{this.doSearch(kw)},onCancel:()=>{// 返回首页}})if(this.suggestions.length>0&&this.keyword.length>0){this.SuggestionList()}else{this.SearchLanding()// 搜索落地页:历史 + 热词}}}privatedebounceSearch(value:string){// 清除上一次定时器if(this.debounceTimer!==-1){clearTimeout(this.debounceTimer)}if(value.trim().length===0){this.suggestions=[]return}this.debounceTimer=setTimeout(()=>{this.fetchSuggestions(value)},300)}privateasyncfetchSuggestions(keyword:string){// 调用联想词接口try{// this.suggestions = await ProductRepository.getSuggestions(keyword)// mock 数据this.suggestions=[keyword+' 新鲜',keyword+' 礼盒装',keyword+' 包邮',keyword+' 当季',]}catch(e){this.suggestions=[]}}}clearTimeout+setTimeout是经典的防抖实现。ArkTS 里这两个方法是全局可用的,不需要额外引入。300ms 的延迟体感上刚好——用户连续打字不会频繁请求,停下来又很快出结果。
搜索历史:PersistentStorage 持久化
搜索历史需要持久化存储,关了 App 再打开还在。HarmonyOS 提供了PersistentStorage,专门做这个事。
// lib_core/src/main/ets/utils/SearchHistoryManager.etsconstHISTORY_KEY='search_history'constMAX_HISTORY=15exportclassSearchHistoryManager{// 初始化时从持久化存储读取staticinit(){PersistentStorage.persistProp<string[]>(HISTORY_KEY,[])}// 获取历史列表staticgetHistory():string[]{returnAppStorage.get<string[]>(HISTORY_KEY)??[]}// 添加搜索记录(去重 + 放最前面)staticaddHistory(keyword:string){lethistory=this.getHistory()// 去重:如果已有,先删掉旧的constindex=history.indexOf(keyword)if(index>=0){history.splice(index,1)}// 插入到头部history.unshift(keyword)// 限制最大数量if(history.length>MAX_HISTORY){history=history.slice(0,MAX_HISTORY)}AppStorage.set<string[]>(HISTORY_KEY,history)}// 清空历史staticclearHistory(){AppStorage.set<string[]>(HISTORY_KEY,[])}}思路很简单:用PersistentStorage.persistProp把搜索历史和磁盘绑定,之后通过AppStorage读写就行。每次搜索的时候调一下addHistory,自动去重、自动限制条数。
在 EntryAbility 的onCreate里记得调SearchHistoryManager.init(),不然第一次打开 App 读不到历史。
搜索落地页:历史 + 热词
用户点进搜索页但还没输入的时候,展示搜索历史 + 热门搜索。这个页面我管它叫「搜索落地页」:
@BuilderSearchLanding(){Scroll(){Column({space:20}){// 搜索历史if(this.historyList.length>0){Column(){Row(){Text('搜索历史').fontSize(16).fontWeight(FontWeight.Medium)Blank()Image($r('app.media.ic_delete')).width(20).height(20).onClick(()=>{// 弹出确认弹窗后清空AlertDialog.show({title:'提示',message:'确认清空搜索历史?',primaryButton:{value:'取消',action:()=>{}},secondaryButton:{value:'清空',action:()=>{SearchHistoryManager.clearHistory()this.historyList=[]}}})})}.width('100%').padding({bottom:12})// 历史标签用 Flex 换行排列Flex({wrap:FlexWrap.Wrap}){ForEach(this.historyList,(item:string)=>{Text(item).fontSize(13).fontColor('#666666').padding({left:12,right:12,top:6,bottom:6}).backgroundColor('#F5F5F5').borderRadius(16).margin({right:8,bottom:8}).onClick(()=>{this.doSearch(item)})},(item:string)=>item)}}.width('100%').padding({left:16,right:16})}// 热门搜索Column(){Text('热门搜索').fontSize(16).fontWeight(FontWeight.Medium).width('100%').padding({bottom:12})ForEach(this.hotKeywords,(item:HotKeyword,index:number)=>{Row(){Text(`${index+1}`).fontSize(15).fontWeight(FontWeight.Bold).fontColor(index<3?'#FF6B35':'#999999').width(24)Text(item.keyword).fontSize(14).fontColor('#333333').layoutWeight(1)if(item.isHot){Text('热').fontSize(10).fontColor(Color.White).backgroundColor('#FF4D4F').borderRadius(4).padding({left:4,right:4,top:2,bottom:2})}}.width('100%').height(44).onClick(()=>{this.doSearch(item.keyword)})},(item:HotKeyword)=>item.keyword)}.width('100%').padding({left:16,right:16})}.width('100%').padding({top:12})}}历史标签用Flex的Wrap模式做流式布局,标签多了自动换行,每个标签是一个胶囊形状(圆角 16vp)。
热门搜索用列表形式,前三名数字用主题色高亮。有的热词后面带个红色的「热」标签,这个效果用一个小 Text 加红色背景就搞定了。
搜索结果页:商品列表 + 筛选排序
用户提交搜索后进入结果页。上面是筛选排序栏,下面是商品列表:
@BuilderSearchResultView(){Column(){// 排序栏Row(){ForEach(this.sortOptions,(option:SortOption)=>{Column({space:2}){Text(option.label).fontSize(14).fontColor(this.currentSort===option.value?'#FF6B35':'#666666').fontWeight(this.currentSort===option.value?FontWeight.Medium:FontWeight.Normal)if(this.currentSort===option.value){Rect().width(20).height(2).fill('#FF6B35').borderRadius(1)}}.layoutWeight(1).height(44).justifyContent(FlexAlign.Center).onClick(()=>{this.currentSort=option.valuethis.doSearch(this.keyword)})},(option:SortOption)=>option.value)}.width('100%').backgroundColor(Color.White)// 搜索结果列表if(this.resultList.length===0&&!this.isSearching){this.EmptyState()}else{List(){ForEach(this.resultList,(item:ProductItem)=>{ListItem(){this.ProductListItem(item)}},(item:ProductItem)=>item.id)}.width('100%').layoutWeight(1)}}}搜索结果用普通的 List 就行,不需要瀑布流——搜索结果强调信息对比,等高卡片更容易横向比较价格。
空状态处理
搜索没有结果的时候不能给用户看空白页。做一个友好的空状态:
@BuilderEmptyState(){Column({space:12}){Image($r('app.media.ic_empty_search')).width(120).height(120).margin({top:80})Text('没有找到相关商品').fontSize(16).fontColor('#999999')Text('换个关键词试试,或者看看热门推荐').fontSize(13).fontColor('#CCCCCC')Button('看看热门推荐').fontSize(14).fontColor('#FF6B35').backgroundColor(Color.Transparent).borderRadius(20).border({width:1,color:'#FF6B35'}).margin({top:20}).onClick(()=>{// 跳转到推荐页})}.width('100%').alignItems(HorizontalAlign.Center)}空状态三要素:一个插图、一句主文案、一个行动按钮。别小看这个页面,很多用户搜不到东西就流失了,一个好的空状态能把人拉回来。
搜索执行的入口
搜索动作触发时记得保存历史:
privatedoSearch(keyword:string){this.keyword=keywordthis.inputValue=keyword SearchHistoryManager.addHistory(keyword)this.historyList=SearchHistoryManager.getHistory()this.suggestions=[]// 清除联想this.fetchResults()// 拉取搜索结果}几点实用建议
防抖时间别太短。200ms 以下用户感知不到延迟,请求照样频繁。300-500ms 是比较舒适的区间。
搜索历史条数要限制。我设的 15 条,太多了用户翻着也累,占存储也没必要。
热词要后端可控。热门搜索是运营位,一定从后端拉取,别写死在客户端。后端可以按时间段、按活动灵活配置。
空状态的推荐内容要真实。别用固定数据糊弄,调一下推荐接口,让用户真的能从这里找到东西。
搜索模块做完,首页的核心交互就差不多了。下一篇我们做分类页面——左边一级分类、右边二级分类、联动滚动,是电商 App 里实现起来最有意思的一个页面。
