• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (c) 2025 Huawei Device Co., Ltd.
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 *     http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15
16import { SceneModuleInfo } from '../model/SceneModuleInfo';
17import { promptAction, ShowDialogSuccessResponse } from '@kit.ArkUI';
18import { waterFlowData } from '../data/WaterFlowData';
19import curves from '@ohos.curves';
20import window from '@ohos.window';
21import common from '@ohos.app.ability.common';
22import { inputMethod } from '@kit.IMEKit';
23
24// 持久化存储保证应用退出后再进入数据还在
25PersistentStorage.persistProp('searchHistoryData', []);
26
27/**
28 * 搜索实现思路:
29 * 1.在进入首页时存储一份初始数据用于查询时筛选数据。
30 * 2.通过输入框onchange接口获取输入框输入的值与ListData中name字段进行对比筛选出符合条件的数据。
31 * 3.将筛选获得的数据通过LazyForeach遍历渲染,点击相应的listitem时通过统一封装的接口buildRouterModel进行跳转。
32 * 4.跳转后将点击的一条数据通过PersistentStorage.persistProp持久化存储下来,保证应用退出后数据依然存在并且实现搜索历史功能。
33 */
34/**
35 * 一镜到底实现思路:
36 * 1.通过bindContentCover全屏模态转场实现对搜索页面显示的控制。
37 * 2.通过transition组件内转场实现搜索页面消失显示过程中的过渡效果。
38 * 3.通过geometryTransition组件内隐式共享元素转场绑定两个搜索框实现传承过渡。
39 * 3.在切换过程中使用animateTo显式动画配合改变搜索框大小实现转换过程中的动画和一镜到底的效果。
40 */
41@Component
42export struct SearchComponent {
43  @StorageLink('listData') searchListData: SceneModuleInfo[] | undefined = waterFlowData; // 搜索原始数据
44  @StorageLink('searchHistoryData') searchHistoryData: SceneModuleInfo[] = []; // 搜索历史数组
45  @State searchContext: string = ''; // 搜索输入内容
46  @State isSearchPageShow: boolean = false; // 搜索页面是否显示标志位
47  @State geometryId: string = ''; // 组件内隐式共享元素转场id
48  @State searchNewListData: SceneModuleInfo[] = [];
49  @State avoidAreaHeight: number = 0;
50  @State screenWidth: number = 0;
51  @StorageLink('context') uiContext: common.UIAbilityContext | undefined = AppStorage.get('context');
52  @State classifyIndex: number = -1;
53  @State searchInput: string = '';
54  @State categoryName: string | undefined = '';
55  @State searchInputWidth: number = 0;
56  private searchClassifyData: String[] = ['UI布局', '动效', '三方库', 'Native', '性能示例', '其他'];
57  // 从AppStorage中获取设别类别,判断是否为折叠屏
58  isFoldable: boolean | undefined = AppStorage.get('isFoldable');
59
60  aboutToAppear(): void {
61    this.setSearchInputWidth();
62    const type = window.AvoidAreaType.TYPE_SYSTEM;
63    window.getLastWindow(this.uiContext, (err, data) => {
64      if (data !== undefined) {
65        let avoidArea = data.getWindowAvoidArea(type);
66        this.avoidAreaHeight = avoidArea.topRect.height;
67      }
68    })
69    let context = getContext() as common.UIAbilityContext
70    window.getLastWindow(context).then((windowClass) => {
71      windowClass.on('windowSizeChange', () => {
72        this.setSearchInputWidth();
73      })
74    })
75  }
76
77  setSearchInputWidth() {
78    let context = getContext() as common.UIAbilityContext;
79    window.getLastWindow(context).then((windowClass) => {
80      const windowsProperties = windowClass.getWindowProperties().windowRect.width;
81      //32为左右边距、24为菜单图标宽度、8为搜索宽与图标之间的距离
82      this.searchInputWidth = px2vp(windowsProperties) - 32 - 24 - 8;
83    })
84  }
85
86  /**
87   * 搜索逻辑
88   * @param value:输入框输入的内容
89   */
90  searchFunc(value: string, category: string | undefined) {
91    let newListData: SceneModuleInfo[] = [];
92    if (this.searchListData !== undefined) {
93      for (let i = 0; i < this.searchListData.length; i++) {
94        // 通过传入的category与范例数据进行匹配,category为undefined和空时表示不进行分类查找
95        const isCategoryItem: boolean =
96          (category === undefined || category?.length === 0) ? true :
97            this.searchListData[i].category.toString() === category;
98
99        // 通过分类信息和includes对输入的字符进行查询
100        if (this.searchListData[i].name.toLowerCase().includes(value.toLowerCase()) && isCategoryItem) {
101          newListData.push(this.searchListData[i]);
102        } else if (this.searchListData[i].serialNumber.toString() === value && isCategoryItem) {
103          newListData.push(this.searchListData[i]);
104        }
105      }
106    }
107    if (value !== '' && newListData.length === 0) {
108      promptAction.showToast({ message: $r('app.string.search_component_content_alarm') });
109    }
110    // 判断是否有输入的值
111    if (value.length !== 0) {
112      this.searchNewListData = newListData;
113    } else {
114      this.searchNewListData = [];
115    }
116  }
117
118  /**
119   * 1.搜索框进入搜索页面animateTo显式动画。
120   * 2.两个搜索框同时绑定同一个geometryId。
121   */
122  private onSearchClicked(): void {
123    this.geometryId = 'search';
124    animateTo({
125      duration: 100,
126      // 构造插值器弹簧曲线对象,生成一条从0到1的动画曲线
127      curve: curves.interpolatingSpring(0, 1, 324, 38)
128    }, () => {
129      this.isSearchPageShow = true;
130    })
131  }
132
133  /**
134   * 1.点击返回箭头,搜索框退出搜索页面animateTo显式动画。
135   * 2.两个搜索框同时绑定同一个geometryId。
136   */
137  private onArrowClicked(): void {
138    this.geometryId = 'search';
139    animateTo({
140      // 构造插值器弹簧曲线对象,生成一条从0到1的动画曲线
141      curve: curves.interpolatingSpring(0, 1, 342, 38)
142    }, () => {
143      this.searchNewListData = [];
144      this.isSearchPageShow = false;
145      this.classifyIndex = -1;
146      this.searchInput = '';
147      this.categoryName = '';
148    })
149  }
150
151  // 点击提示列表/历史记录进入范例页
152  private onItemClicked(): void {
153    this.geometryId = 'search';
154    animateTo({
155      curve: Curve.Ease,
156      duration: 20
157    }, () => {
158      this.searchNewListData = [];
159      this.isSearchPageShow = false;
160    })
161  }
162
163  /**
164   * 当开始滑动搜索列表、点击历史搜索空白部分关闭键盘
165   * @param event 触屏事件
166   */
167  private onTouchDown(event: TouchEvent): void {
168    let inputMethodController = inputMethod.getController();
169    inputMethodController.stopInputSession()
170  }
171
172  /**
173   * 搜索添加分类选项
174   * */
175  @Builder
176  searchClassificationSelection() {
177    Column() {
178      Column() {
179        Text($r('app.string.search_component_search_classify_fixed_words'))
180          .fontColor($r('app.color.search_classify_fixed_words_color'))
181      }
182      .width('100%')
183      .alignItems(HorizontalAlign.Start)
184      .margin({ bottom: $r('app.integer.search_component_classify_description_margin') })
185
186      Grid() {
187        // TODO:知识点:使用ForEach加载分类搜索列表数据
188        ForEach(this.searchClassifyData, (item: string, index: number) => {
189          GridItem() {
190            Text(item)
191              .fontColor(this.classifyIndex === index ? Color.White : Color.Black)
192              .opacity(this.classifyIndex === index ? 1 : 0.8)
193          }
194          .height($r('app.integer.search_component_classify_category_item_height'))
195          .width($r('app.integer.search_component_classify_category_item_weight'))
196          .backgroundColor(this.classifyIndex === index ? $r('app.color.search_classify_category_chosen_color') :
197          $r('app.color.search_classify_category_not_chosen_color'))
198          .borderRadius($r('app.integer.search_component_classify_category_border_radius'))
199          .onClick(() => {
200            this.classifyIndex = this.classifyIndex === index ? -1 : index;
201            this.categoryName = this.classifyIndex === index ? item : '';
202            this.searchFunc(this.searchInput, this.categoryName);
203          })
204        }, (item: string) => item.toString())
205      }
206      .height($r('app.integer.search_component_classify_category_height'))
207      .width('100%')
208      .columnsTemplate('1fr 1fr 1fr')
209      .rowsGap($r('app.integer.search_component_classify_category_rows_gap'))
210      .columnsGap($r('app.integer.search_component_classify_category_columns_gap'))
211    }
212    .width('100%')
213    .padding($r('app.integer.search_component_classify_category_padding'))
214    .border({
215      width: { bottom: $r('app.integer.search_component_classify_category_split_line') },
216      color: { bottom: $r('app.color.search_classify_split_line_color') }
217    })
218  }
219
220  /**
221   * 增加搜索详情页第一条,显示固定文字
222   */
223  @Builder
224  searchSampleNumber() {
225    Column() {
226      Text() {
227        Span(this.searchNewListData.length.toString())
228        Span($r('app.string.search_component_search_item_fixed_words'))
229      }
230      .fontSize($r('app.integer.search_component_search_number_font_size'))
231      .fontColor($r('app.color.search_item_number_color'))
232    }
233  }
234
235  @Builder
236  searchPage() {
237    Column() {
238      Row() {
239        Row() {
240          Image($r('app.media.search_component_arrow_left'))
241            .width($r('app.integer.search_component_image_left_width'))
242            .onClick(() => {
243              this.onArrowClicked();
244            })// TODO:知识点:通过transition属性配置转场参数,在组件插入和删除时显示过渡动效
245            .transition(TransitionEffect.asymmetric(
246              TransitionEffect.opacity(0)
247                .animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 200, delay: 150 }),
248              TransitionEffect.opacity(0)
249                .animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 200 }),
250            ))
251        }
252        .justifyContent(FlexAlign.Center)
253        .width($r('app.integer.search_component_image_left_background_size'))
254        .height($r('app.integer.search_component_image_left_background_size'))
255        .borderRadius($r('app.integer.search_component_image_left_border_radius'))
256        .backgroundColor('#E5E7E9')
257
258        // TODO:知识点:使用搜索框组件,不需要自己进行封装搜索样式
259        Search({ value: this.searchContext, placeholder: $r('app.string.search_component_search_placeholder') })
260          .width($r('app.integer.search_component_search_input_width'))
261          .textFont({ weight: 500 })
262          .searchButton('搜索', { fontSize: $r('app.integer.search_component_search_button_text_size') })
263          .defaultFocus(true)// 默认获取焦点拉起键盘
264          .onChange((value: string) => {
265            this.searchFunc(value, this.categoryName);
266            this.searchInput = value;
267          })
268          .borderRadius($r('app.integer.search_component_search_border_radius'))
269          .geometryTransition(this.geometryId, { follow: true })
270          .layoutWeight(1)
271          .height($r('app.string.search_component_search_height'))
272          .margin({ left: $r('app.integer.search_component_search_margin') })
273          .backgroundColor($r('app.string.search_component_search_background_color'))
274      }
275      .padding({
276        left: $r('app.integer.search_component_search_padding'),
277        right: $r('app.integer.search_component_search_padding')
278      })
279      .alignSelf(ItemAlign.Start)
280
281      // 搜索分类
282      this.searchClassificationSelection()
283
284      // 搜索历史
285      Column() {
286        // 搜索历史标题区
287        Row() {
288          Text($r('app.string.search_component_search_history'))
289            .fontSize($r('app.string.search_component_search_history_font_size2'))
290            .fontWeight(FontWeight.Bold)
291          Blank()
292          Image($r('app.media.search_component_ic_public_delete'))
293            .width($r('app.string.search_component_search_history_delete_size'))
294            .id('delete_history')
295            .onClick(() => {
296              // 清空历史记录-确认弹框
297              promptAction.showDialog({
298                message: $r('app.string.search_component_search_delete_title'),
299                alignment: DialogAlignment.Center,
300                buttons: [
301                  {
302                    text: $r('app.string.search_component_delete_back'),
303                    color: $r('app.string.search_component_button_text_color')
304                  },
305                  {
306                    text: $r('app.string.search_component_delete_ok'),
307                    color: $r('app.string.search_component_button_text_color')
308                  }
309                ]
310              }).then((data: ShowDialogSuccessResponse) => {
311                // 点击删除
312                if (data.index === 1) {
313                  this.searchHistoryData = [];
314                }
315              })
316            })
317        }
318        .visibility(this.searchHistoryData.length === 0 || this.searchContext.length !== 0 ||
319          this.searchNewListData.length !== 0 ? Visibility.None : Visibility.Visible) // 没有搜索历史时隐藏
320        .height($r('app.string.search_component_search_history_delete_size'))
321        .width('100%')
322        .margin({ top: $r('app.string.search_component_search_history_text_padding_margin1') })
323
324        //搜索历史内容区
325        Scroll() {
326          Flex({ wrap: FlexWrap.Wrap }) {
327            // 首次进入页面就需要全部加载不需要使用LazyForeach懒加载
328            ForEach(this.searchHistoryData, (item: SceneModuleInfo) => {
329              Column() {
330                Text(item.name)
331                  .fontSize($r('app.string.search_component_search_history_font_size3'))
332                  .backgroundColor($r('app.string.search_component_search_list_text_color'))
333                  .padding($r('app.string.search_component_search_history_text_padding_margin3'))
334                  .borderRadius($r('app.string.search_component_main_page_top_borderRadius'))
335              }
336              .margin({ top: $r('app.string.search_component_search_history_text_padding_margin4') })
337              .padding({ right: $r('app.string.search_component_search_history_text_padding_margin2') })
338              .onClick(() => {
339                this.onItemClicked();
340                // 点击的项提到历史记录的最前面
341                this.searchHistoryData.map((historyItem, index) => {
342                  if (historyItem === item) {
343                    this.searchHistoryData.unshift(this.searchHistoryData.splice(index, 1)[0]);
344                  }
345                })
346              })
347            })
348          }
349          .margin({ top: $r('app.string.search_component_search_history_text_padding_margin3') })
350        }
351        .scrollBar(BarState.Off) // 滚动条常驻不显示
352        .align(Alignment.TopStart)
353        .visibility(this.searchHistoryData.length === 0 || this.searchContext.length !== 0 ||
354          this.searchNewListData.length !== 0 ? Visibility.None : Visibility.Visible) // 没有搜索历史时隐藏
355        .height($r('app.string.search_component_scroll_height'))
356        .onTouch((event) => {
357          switch (event.type) {
358            case TouchType.Down:
359              this.onTouchDown(event);
360              break;
361          }
362          event.stopPropagation(); // 阻止冒泡
363        })
364      }
365      .padding({
366        left: $r('app.integer.search_component_history_padding'),
367        right: $r('app.integer.search_component_history_padding')
368      })
369      .alignItems(HorizontalAlign.Start)
370      // TODO:知识点:通过transition属性配置转场参数,在组件插入和删除时显示过渡动效。非对称对称转场,第一个为出现动效有150的延迟,第二个为消失动效
371      .transition(TransitionEffect.asymmetric(
372        TransitionEffect.opacity(0)
373          .animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 350 })
374          .combine(TransitionEffect.translate({ y: 30 })),
375        TransitionEffect.opacity(0)
376          .animation({ curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1), duration: 350 })
377          .combine(TransitionEffect.translate({ y: 30 })),
378      ))
379
380
381      List() {
382        // TODO:知识点:使用LazyForEach加载搜索结果列表,可以按需加载,解决一次性加载全部列表数据引起的卡顿问题,提高页面响应速度
383        ForEach(this.searchNewListData, (item: SceneModuleInfo, index: number) => {
384          ListItem() {
385            Column() {
386              Row() {
387                Row() {
388                  Image($r('app.media.search_component_search'))
389                    .width($r('app.integer.search_component_list_size'))
390                    .height($r('app.integer.search_component_search_icon_height'))
391                  Text(item.serialNumber.toString() + '.' + item.name)
392                    .maxLines(1)
393                    .width($r('app.integer.search_component_search_text_width'))
394                    .fontWeight(500)
395                    .fontSize($r('app.string.search_component_search_history_font_size2'))
396                    .opacity(0.9)
397                    .margin({ left: $r('app.string.search_component_search_history_text_padding_margin2') })
398                    .textOverflow({ overflow: TextOverflow.Ellipsis })
399                }
400
401                Column() {
402                  this.searchSampleNumber()
403                }
404                .visibility(index === 0 ? Visibility.Visible : Visibility.None)
405              }
406              .alignItems(VerticalAlign.Center)
407              .justifyContent(FlexAlign.SpaceBetween)
408            }
409            .width('100%')
410            .alignItems(HorizontalAlign.Start)
411          }
412          .id('itemId')
413          .width('100%')
414          .margin({ top: $r('app.string.search_component_search_history_text_padding_margin1') })
415          .onClick(() => {
416            if (!this.searchHistoryData.includes(item)) {
417              // 更新搜索历史数据,插入数组最前侧
418              this.searchHistoryData.unshift(item);
419            } else {
420              // 搜索点击的为已有历史记录内容,该记录提到最前
421              this.searchHistoryData.map((historyItem, index) => {
422                if (historyItem === item) {
423                  this.searchHistoryData.unshift(this.searchHistoryData.splice(index, 1)[0]);
424                }
425              })
426
427            }
428            this.onItemClicked();
429          })
430        }, (item: SceneModuleInfo) => JSON.stringify(item))
431      }
432      .onTouch((event) => {
433        switch (event.type) {
434          case TouchType.Down:
435            this.onTouchDown(event);
436            break;
437        }
438        event.stopPropagation(); // 阻止冒泡
439      })
440      .height($r('app.integer.search_component_list_height'))
441      .margin({
442        left: $r('app.integer.search_component_list_margin'),
443        right: $r('app.integer.search_component_list_margin')
444      })
445      .edgeEffect(EdgeEffect.Spring)
446      .sticky(StickyStyle.Header)
447      .chainAnimation(false)
448      .transition({ opacity: 0 })
449      .scrollBar(BarState.Off)
450      .id('search_result_list')
451    }
452    .transition(TransitionEffect.opacity(0))
453    .backgroundColor(Color.White)
454    .padding({
455      top: px2vp(this.avoidAreaHeight)
456    })
457    .width('100%')
458    .height('120%')
459  }
460
461  build() {
462    // 顶部搜索框
463    Search({ placeholder: $r('app.string.search_component_search_placeholder') })
464      .id('searchCaseTitle')
465      .backgroundColor(Color.Black)
466      .focusOnTouch(false)
467      .focusable(false)
468      .enableKeyboardOnFocus(false)
469      .backgroundColor('#E7E9E8')
470      .width(this.searchInputWidth)
471      .height($r('app.integer.search_component_home_search_height'))
472      .onClick(() => {
473        this.onSearchClicked();
474      })
475      .geometryTransition(this.geometryId, { follow: true })
476      .transition(TransitionEffect.OPACITY.animation({
477        duration: 200,
478        curve: curves.cubicBezierCurve(0.33, 0, 0.67, 1)// 搜索框转场过渡动画,cubicBezierCurve为三阶贝塞尔曲线动画
479      }))
480      .backgroundColor('#E7E9E8')
481      .borderRadius($r('app.integer.search_component_search_border_radius'))// TODO:知识点:通过bindContentCover属性为组件绑定全屏模态页面,在组件插入和删除时可通过设置转场参数ModalTransition显示过渡动效
482      .bindContentCover(this.isSearchPageShow, this.searchPage(), {
483        modalTransition: ModalTransition.NONE,
484        onDisappear: () => {
485          this.onArrowClicked();
486          this.searchContext = '';
487        }
488      })
489  }
490}
491