HarmonyOS 鸿蒙Next打造自己的搜索入口

HarmonyOS 鸿蒙Next打造自己的搜索入口

背景

几乎每家应用中都带有搜索功能,关于这个功能的页面不是特别复杂,但如果要追究其背后的一系列逻辑,可能是整个应用中最复杂的一个功能。今天主要实践目标,会抛开复杂的逻辑,尝试纯粹实现一个“搜索主页”,主要包含,输入框文字输入,热门词展示,热门帖子展示。全篇主要使用到的控件是TextInput, Flex, Swiper。为了贴近实战,文字输入过程中,也增加了联想词功能。整个示例将在模拟状态下完成,不做任何网络请求。

功能清单

  1. 输入框 - TextInput用法
  2. 按钮搜索词删除 - 触摸事件透传用法
  3. 搜索按钮 - 页面返回用法
  4. 联想词 - Span用法,if…else 渲染用法
  5. 历史搜索词 - 行数限制,排序
  6. 热门搜索词 - 换行布局,行为识别(打开链接,发起搜索)
  7. 热门帖子 - Swiper用法,Span用法

效果

图片

Screenshot_20231219153940743.png

Screenshot_20231219152615992.png

布局结构

整体页面分为上下布局两大部分:

  • 搜索栏
  • 可滚动内容区域

图片

布局结构图

开始

搜索框

HarmonyOS 提供了Search控件, 这种样式不太满足今天要做的需求,所以我还是准备采用TextInput控件重新定制

图片

搜索框示例

预期的搜索框需要包含基础的三个功能

  • 文字输入
  • 文字删除
  • 提交已输入的文字(即:准备发起搜索)

图片

搜索框细节

这个样式的实现方式,我采用了左右布局,左布局采用叠加布局方式,翻译为代码,表现形式如下

//一. (左布局)输入框 + (右布局)搜索按钮
Row() {
  Stack() {
    // 输入框
    TextInput()
    // 放大镜图片 + 删除按钮图片 
    Row() {
      Image(放大镜图片)
      if (this.currentInputBoxContent.length != 0) {
         Image(删除按钮图片)
     }
     .hitTestBehavior(HitTestMode.None) // NOTE:父组件不消耗触摸事件
     .justifyContent(FlexAlign.SpaceBetween) // NOTE: 两端对齐
  }
  //搜索按钮 / 返回按钮
  Text(this.searchButtonText)
}

这里的Stack布局方式,实际中会引发一个问题:点击TextInput控件时非常不灵敏,实际情况是“放大镜图片+删除按钮图解片”Row布局,消耗了输入框的触摸事件。 解决这个问题,可以使用系统提供的hitTestBehavior(HitTestMode.None)这个接口,这个接口的参数提供了4种响应触摸事件的功能

图片

解决触摸事件问题

所以解决此问题只需要添加完这个接口即可恢复正常触摸事件:见代码中的 父组件不消耗触摸事件

//一. (左布局)输入框 + (右布局)搜索按钮
Row() {
  Stack() {
    // 输入框
    TextInput()
    // 放大镜图片 + 删除按钮图片 
    Row() {
      Image(放大镜图片)
      if (this.currentInputBoxContent.length != 0) {
         Image(删除按钮图片)
     }
     .hitTestBehavior(HitTestMode.None) // NOTE:父组件不消耗触摸事件
     .justifyContent(FlexAlign.SpaceBetween) // NOTE: 两端对齐
  }
  //搜索按钮 / 返回按钮
  Text(this.searchButtonText)
}

由于采用的是Stack叠加布局方式,所以要解决的第二个问题是如何将Row布局两边对齐Stack即,处于TextInput控件的两端,根据Row容器内子元素在水平方向上的排列指导可知,在Row布局上添加justifyContent(FlexAlign.SpaceBetween)这句代码即可。

图片

Row布局示意图

变更后的代码

//一. (左布局)输入框 + (右布局)搜索按钮
Row() {
  Stack() {
    // 输入框
    TextInput()
    // 放大镜图片 + 删除按钮图片 
    Row() {
      Image(放大镜图片)
      if (this.currentInputBoxContent.length != 0) {
         Image(删除按钮图片)
     }
     .hitTestBehavior(HitTestMode.None) // NOTE:父组件不消耗触摸事件
     .justifyContent(FlexAlign.SpaceBetween) // NOTE: 两端对齐
  }
  //搜索按钮 / 返回按钮
  Text(this.searchButtonText)
}

关于TextInput 组件的接口说明,其实官方文档已经写的十分清楚,这里我通过示例再稍稍做一说明

TextInput的构造函数参数说明

  • placeholder: 俗称:按提示,提示词,引导词
  • text: 输入框已输入的文字内容

TextInput的属性方法onChange

用来监听最新已输入的文字

  • 这个方法中,我们可以通过判断内容长度,来设置控制中的搜索按钮文字,如果有内容,按钮文案将变为"搜索",反之,按钮文案变为“取消”,即点击之后将关闭当前页面; 同时请求远端联想词的功能也是在这里触发,注意:本篇文章中的联想词仅仅是本地模拟的数据,没有进行网络请求,也没有模拟网络延时加载,在真实场景中一定要注意用户的操作行为应该中断本次联想词的网络请求(即使网络请求已经发出去,回来之后,也要扔掉拿到的联想词数据)。

TextInput的属性方法enterKeyType

这个用来修改软件盘上的回车键文字提示,这个设置的值为EnterKeyType.Search,所以在中文模式下,你会发现键盘上的文字为:搜索

图片

输入框键盘

TextInput的实现代码

TextInput({
  placeholder: '热词搜索',
  text: this.currentInputBoxContent
})
.height('40vp')
.fontSize('20fp')
.enterKeyType(EnterKeyType.Search)
.placeholderColor(Color.Grey)
.placeholderFont({ size: '14vp', weight: 400 })
.width('100%')
.padding({ left: '35vp', right: '35vp' })
.borderStyle(BorderStyle.Solid)
.borderWidth('1vp')
.borderColor(Color.Red)
.onChange(currentContent => {
  this.currentInputBoxContent = currentContent
  if (this.currentInputBoxContent.length != 0) {
    this.searchButtonText = '搜索'
    this.showThinkWord = true
    this.simulatorThinkWord()
  } else {
    this.searchButtonText = '取消'
    this.showThinkWord = false
  }
})
.onSubmit(enterKey => {
  this.submitData(new HistoryWordModel(0, this.currentInputBoxContent));
})

至此,一个完整的输入框已完美的完成布局。

历史搜索词

一个搜索的新手产品,在讲解这部分需求时,会使用简短的话术:把搜索过的内容显示出来。

实际情况是比较严谨复杂的,最多多展示多少行? 每个历史词最多展示多少个字符? 要不要识别词性?…针对这些严格的逻辑,研发人员需要优先解决动态布局的问题,剩下的仅仅是堆积代码。

在Android系统中,针对这种布局场景,需要代码动态实现,即采用Java方式布局,不幸的是HarmonyOS 中没有这个说法。

解决方案:

给历史词变量添加 @State 修饰,根据视图高度动态计算行数,然后动态删除多余关键词记录

注意:@State 修饰的Array无法对sort方法生效,结合场景描述即:最新搜索的关键词,都要排在第一个位置,所以每发起一次搜索,都要对Array类型的变量进行一次排序,由于@State的限制,我们需要在中间中转一次。

既然已经知道问题,那么先看一下布局代码,然后继续完成需求

首先,对动态布局的需求来讲,HarmonyOS中,貌似只能用Flex容器来解决,因为它不仅可以包含子组件,也有自动换行功能,所这里我采用的是Flex容器,如果你要更好的方案,欢迎留言交流

通过视图高度动态计算行数,可以依赖onAreaChange接口,在其回调中,通过每次的新值结构体(即Area),获取当前布局高度,然后除以第一次获取到的高度,这样即可完成行数的测算

关于Flex自动换行功能,这个要依赖于一个参数wrap: FlexWrap.Wrap

if (this.historyWords.length != 0) {

  Row() {

    Text('历史搜索')
      .fontSize('20fp')
      .fontWeight(FontWeight.Bold)

    Image($r('app.media.ic_public_delete')).width('20vp').height('20vp')
      .onClick(() => {
        this.dialogController.open()
      })

  }.width('100%')
  .margin({ top: '20vp' })
  .padding({ left: '10vp', right: '10vp' })
  .justifyContent(FlexAlign.SpaceBetween)

  Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
    ForEach(this.historyWords, (item: HistoryWordModel, index) => {

      Text(item.word)
        .fontSize(15)
        .margin(5)
        .fontColor('#5d5d5d')
        .maxLines(1)
        .backgroundColor('#f6f6f6')
        .padding({ left: 20, right: 20, top: 5, bottom: 5 })
        .borderRadius('30vp')
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .onClick(()=>{
          this.submitData(item);
        })
    })
  }.width('100%')
  .margin({ top: '12vp' })
  .onAreaChange((oldValue: Area, newValue: Area) => {

    let newHeight = newValue.height as number

    //全局声明一个历史词单行高度变量,初始值设置为0,一旦产生历史词,将行高设置为此值
    //后续将以此值为标准来计算历史词行数
    if(this.currentHistoryHeight == 0){
       this.currentHistoryHeight = newHeight
    }

    //这里仅仅取整
    this.currentLineNumbs = newHeight / this.currentHistoryHeight

    //MAX_LINES 代表最大行数
    if (this.currentLineNumbs >= MAX_LINES) {
    
      //删除一个历史词,由于historyWords添加了@State修饰,所以数据发生变化后,页面会刷新
      //页面刷新后,又会重新触发此方法
      this.historyWords = this.historyWords.slice(0, this.historyWords.length-1)
    }

  })

}

刚刚提到过一个问题,@State 修饰的Array变量是无法进行排序的。应对这个问题,可以在中间中转一下,即声明一个局部Array,先将历史记录赋值给它,让这个局部Array参与sort,然后清空@State修饰的Array变量,最终将局部Array赋值给@State修饰的Array变量,描述有点繁琐,直接看代码。

只要发起搜索行为,都会使用到此方法,另外注意阅读代码注释有NOTE的文字

submitData(wordModel: HistoryWordModel) {
  if (wordModel.word.length != 0) {

    //标识本次搜索的关键词是否存在
    let exist: boolean = false
    
    //如果搜索关键词存在,记录其位置,如果发现其已经是第一个位置,则不进行排序刷新动作
    let existIndex: number = -1

     //判断搜索关键是否存在于历史搜索词列表中
    this.historyWords.forEach((item, index) => {
         if(item.word === wordModel.word){
           //如果本次搜索关键词已经处于历史搜索词的第一个位置,不做删除动作
           if(index != 0){
             //如果存在,先删除历史词列表中的这个关键词
             this.historyWords.splice(index, 1)
           }
           exist = true
           existIndex = index
         }
    });

    //本次搜索关键词在历史搜索词列表中处于第一个位置,因此不做任何额外处理
    //NOTE:真实场景中,这里除了重置状态,应该发起网络请求
    if(existIndex == 0){
      console.log('不需要刷新页面')
      this.currentInputBoxContent = ''
      this.searchButtonText = '取消'
      this.showThinkWord = false
      return
    }

    if(!exist){
      //如果本次搜索关键词在历史词列表中不存在,则将其加入其中
      wordModel.index = this.historyWordIndex++
      this.historyWords.push(wordModel)
    } else {
      //如果本次搜索关键词已存在于历史词列表中,将其对应的下标加1,因为后续会用下表排序
      //下标越大代表离发生过的搜索行为离当前越近
      this.historyWordIndex++
      this.historyWords.push(new HistoryWordModel(this.historyWordIndex, wordModel.word, wordModel.link))
    }

    //NOTE:这个就是中转排序的起始代码
    let Test: Array<HistoryWordModel> = []

    this.historyWords.forEach((item, index) => {
      Test.push(item)
    })

    Test.sort((a:HistoryWordModel, b:HistoryWordModel) => {
       return b.index - a.index
    })

    this.historyWords.length = 0

    Test.forEach((item, index) => {
      this.historyWords.push(item)
    })
    //NOTE:这个就是中转排序的结束代码

    this.currentInputBoxContent = ''
    this.searchButtonText = '取消'
    this.showThinkWord = false
  } else {
    Prompt.showToast({
      message: '请输入关键词',
      bottom: px2vp(this.toastBottom)
    })
  }
}

至此,历史记录也实现完成。

联想词实现

在已有的搜索场景中,我们都知道,当发起联想词时,历史搜索记录,热词等等,均不会出现在当前屏幕中,为了实现此种效果,我采用了Stack控件叠加覆盖机制和if…else渲染机制,最终实现完成之后,发现没必要使用Stack控件,因为用了if…else布局后,像当于会动态挂载和卸载试图。

联想词实现还会碰到的一个问题:高亮关键词,按照HarmonyOS 的布局机制,一切布局都应该提前计算好,全量布局多种场景样式,以if…else机制为基础,完整最终的业务场景效果。

那么,如何提前计算好数据呢?高亮词在数据结构上我们可以分为三段:前,中,后。如何理解呢?比如搜索关键词“1”,那我的联想词无非就几种情况 123,213,1,231,那么,我声明三个变量s, m, e, 分别代表前,中,后,此时你会发现三个变量是完全可以覆盖所有的匹配场景的。这种方式暂且命名为:分割,“分割”后,在最终展示时,由于需要高亮文字,所以,我们还需要知晓已“分割”的文字中,到底哪一段应该高亮,基于此种考虑,需要额外声明高亮下标,参考“前,中,后”,将下标分别定义为0,1,2

具体实现,看代码吧

Stack() {
  //联想词需要展示时
  if (this.showThinkWord) {
    Column() {
      //遍历联想词
      ForEach(this.thinkWords, (item: ThinkWordModel, index) => {

        //NOTE: Span控件可以实现文字分段多种样式
        Text() {
          //判断一条联想词数据的“前”
          if (item.wordStart && item.wordStart.length != 0) {
            Span(item.wordStart)
              .fontSize(18)
              .fontColor(item.highLightIndex == 0 ? item.highLightColor : item.normalColor)
          }
          //判断一条联想词数据的“中”
          if (item.wordMid && item.wordMid.length != 0) {
            Span(item.wordMid)
              .fontSize(18)
              .fontColor(item.highLightIndex == 1 ? item.highLightColor : item.normalColor)
          }
          //判断一条联想词数据的“后”
          if (item.wordEnd && item.wordEnd.length != 0) {
            Span(item.wordEnd)
              .fontSize(18)
              .fontColor(item.highLightIndex == 2 ? item.highLightColor : item.normalColor)
          }
        }......
      })
    }......

  } else {
  
// 没有联想词时,系统机制会讲联想词视图卸载掉,即if中的视图会完全从视图节点中拿掉
    Column() {
      //二. 搜索历史
      if (this.historyWords.length != 0) {
        ......
      }

      //三. 热门搜索
      Text('热门搜索')......

      Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) {
        ForEach(this.hotWords, (item: HotWordsModel, index) => {
          Text(item.word)......
        })
      }
   

      //四. 热门帖子
      Text('热门帖子')
      Swiper(this.swiperController) {

        LazyForEach(this.data, (item: string, index: number) => {
           ......
        }, item => item)

      }
     
    }

  }

}

热门帖子实现

在整个搜索主页中,这个功能可能算比较简单的,在Scroll控件中放置Swiper控件,然后按照官方文档,循环塞入数据,整个效果即可实现。 这个里边用到了Span,由于我们在联想词实现时已经实践过了Span, 这里就不再描述。

NOTE:为了迎合需求,将滑动指示隐藏掉,indicator(false) false代表隐藏滑动指示

//四. 热门帖子
Text('热门帖子')
  .fontSize('20fp')
  .width('100%')
  .fontWeight(FontWeight.Bold)
  .margin({ left: '10vp', top: '20vp' })

Swiper(this.swiperController) {

  //data仅仅是为了循环,数据总个数是3  
  LazyForEach(this.data, (item: string, index: number) => {

    //每一页Swiper内容视图,通过 @Builder 修饰的方法进行一次封装
    if (index == 0) {
      this.swiperList(this.hotTopicList1)
    } else if (index == 1) {
      this.swiperList(this.hotTopicList2)
    } else if (index == 2) {
      this.swiperList(this.hotTopicList3)
    }

  }, item => item)

}
.padding({ bottom: '50vp' })
.displayMode(SwiperDisplayMode.AutoLinear)
.margin({ top: '12vp' })
.cachedCount(2)
.index(1)
.indicator(false)
.loop(true)
.itemSpace(0)
.curve(Curve.Linear)

总结

  • 对于Android&iOS开发者来讲,在HarmonyOS中实现动态布局,还是非常容易陷入之前的开发思路中
  • 新的平台,熟悉API很重要

更多关于HarmonyOS 鸿蒙Next打造自己的搜索入口的实战教程也可以访问 https://www.itying.com/category-93-b0.html

5 回复

遇到了问题:submitData()方法中的逻辑是什么呀,怎么感觉有些问题,可以看一下这个帖子,这么写会有什么风险吗

https://developer.huawei.com/consumer/cn/forum/topic/0204144948828816057?fid=0102683795438680754

更多关于HarmonyOS 鸿蒙Next打造自己的搜索入口的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


感谢分享,很有帮助!

搜索框那样感觉没必要用stack啊

不然,放大镜图片和删除图片怎么放置,

在HarmonyOS鸿蒙Next中,您可以通过开发自定义服务卡片或应用来打造自己的搜索入口。首先,利用HarmonyOS的分布式能力,整合多设备数据源;其次,使用ArkUI框架设计用户界面,确保跨设备一致性;最后,通过HarmonyOS的API实现搜索功能,支持语音、文本等多种输入方式。这样,您可以为用户提供高效、便捷的搜索体验,同时充分利用鸿蒙系统的生态优势。

回到顶部