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