1# 应用市场首页 2 3 4本小节将以应用市场首页为例,介绍如何使用自适应布局能力和响应式布局能力适配不同尺寸窗口。 5 6 7## 页面设计 8 9一个典型的应用市场首页的UX设计如下所示。 10 11 | sm | md | lg | 12| -------- | -------- | -------- | 13|  |  |  | 14 15观察应用市场首页的页面设计,不同断点下的页面设计有较多相似的地方。 16 17据此,我们可以将页面分拆为多个组成部分。 18 191. 底部/侧边导航栏 20 212. 标题栏与搜索栏 22 233. 运营横幅 24 254. 快捷入口 26 275. 精品应用 28 29 | sm | md | lg | 30| -------- | -------- | -------- | 31|  |  |  | 32 33接下来我们逐一分析各部分的实现。 34 35 36## 底部/侧边导航栏 37 38在sm和md断点下,导航栏在底部;在lg断点下,导航栏在左侧。可以通过[Tab组件](../../reference/apis-arkui/arkui-ts/ts-container-tabs.md)的barPosition和vertical属性控制TabBar的位置,同时还可以通过barWidth和barHeight属性控制TabBar的尺寸。 39```ts 40//BreakpointSystem.ets 41import mediaQuery from '@ohos.mediaquery' 42 43export default class BreakpointSystem { 44 private currentBreakpoint: string = 'md' 45 private smListener?: mediaQuery.MediaQueryListener 46 private mdListener?:mediaQuery.MediaQueryListener 47 private lgListener?: mediaQuery.MediaQueryListener 48 49 private updateCurrentBreakpoint(breakpoint: string) { 50 if (this.currentBreakpoint !== breakpoint) { 51 this.currentBreakpoint = breakpoint 52 AppStorage.Set<string>('currentBreakpoint', this.currentBreakpoint) 53 } 54 } 55 56 private isBreakpointSM = (mediaQueryResult:mediaQuery.MediaQueryResult) => { 57 if (mediaQueryResult.matches) { 58 this.updateCurrentBreakpoint('sm') 59 } 60 } 61 62 private isBreakpointMD = (mediaQueryResult:mediaQuery.MediaQueryResult) => { 63 if (mediaQueryResult.matches) { 64 this.updateCurrentBreakpoint('md') 65 } 66 } 67 68 private isBreakpointLG = (mediaQueryResult:mediaQuery.MediaQueryResult) => { 69 if (mediaQueryResult.matches) { 70 this.updateCurrentBreakpoint('lg') 71 } 72 } 73 74 public register() { 75 this.smListener = mediaQuery.matchMediaSync('(320vp<=width<600vp)') 76 this.smListener.on('change', this.isBreakpointSM) 77 this.mdListener = mediaQuery.matchMediaSync('(600vp<=width<840vp)') 78 this.mdListener.on('change', this.isBreakpointMD) 79 this.lgListener = mediaQuery.matchMediaSync('(840vp<=width)') 80 this.lgListener.on('change', this.isBreakpointLG) 81 } 82 83 public unregister() { 84 this.smListener?.off('change', this.isBreakpointSM) 85 this.mdListener?.off('change', this.isBreakpointMD) 86 this.lgListener?.off('change', this.isBreakpointLG) 87 } 88} 89``` 90 91```ts 92import Home from '../common/Home'; //组件请参考相关实例 93import TabBarItem from '../common/TabBarItem'; 94import BreakpointSystem from '../common/BreakpointSystem' 95 96@Entry 97@Component 98struct Index { 99 @State currentIndex: number = 0 100 @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md' 101 private breakpointSystem: BreakpointSystem = new BreakpointSystem() 102 private onTabChange = (index: number) => { 103 this.currentIndex = index 104 } 105 106 aboutToAppear() { 107 this.breakpointSystem.register() 108 } 109 110 aboutToDisappear() { 111 this.breakpointSystem.unregister() 112 } 113 114 @Builder 115 tabItem(index: number, title: Resource, icon: Resource, iconSelected: Resource) { 116 TabBarItem({ 117 index: index, 118 currentIndex: this.currentIndex, 119 title: title, 120 icon: icon, 121 iconSelected: iconSelected 122 }) 123 } 124 125 build() { 126 // 设置TabBar在主轴方向起始或结尾位置 127 Tabs({ barPosition: this.currentBreakpoint === "lg" ? BarPosition.Start : BarPosition.End }) { 128 // 首页 129 TabContent() { 130 Home() 131 } 132 .tabBar(this.tabItem(0, $r('app.string.tabBar1'), $r('app.media.ic_home_normal'), 133 $r('app.media.ic_home_actived'))) 134 135 TabContent() { 136 }.tabBar(this.tabItem(1, $r('app.string.tabBar2'), $r('app.media.ic_app_normal'), $r('app.media.ic_app_actived'))) 137 138 TabContent() { 139 } 140 .tabBar(this.tabItem(2, $r('app.string.tabBar3'), $r('app.media.ic_game_normal'), 141 $r('app.media.ic_mine_actived'))) 142 143 TabContent() { 144 } 145 .tabBar(this.tabItem(3, $r('app.string.tabBar4'), $r('app.media.ic_search_normal'), 146 $r('app.media.ic_search_actived'))) 147 148 TabContent() { 149 } 150 .tabBar(this.tabItem(4, $r('app.string.tabBar4'), $r('app.media.ic_mine_normal'), 151 $r('app.media.ic_mine_actived'))) 152 } 153 .backgroundColor('#F1F3F5') 154 .barMode(BarMode.Fixed) 155 .barWidth(this.currentBreakpoint === "lg" ? 96 : '100%') 156 .barHeight(this.currentBreakpoint === "lg" ? '60%' : 56) 157 // 设置TabBar放置在水平或垂直方向 158 .vertical(this.currentBreakpoint === "lg") 159 } 160} 161``` 162 163另外在sm及lg断点下,TabBar中各个Item的图标和文字是按照垂直方向排布的,在md断点下,TabBar中各个Item的图标和文字是按照水平方向排布的。 164 165 166```ts 167interface GeneratedObjectLiteralInterface_1 { 168 NORMAL: string; 169 SELECTED: string; 170} 171 172const TitleColor: GeneratedObjectLiteralInterface_1 = { 173 NORMAL: '#999', 174 SELECTED: '#0A59F7' 175} 176 177@Component 178export default struct TabBarItem { 179 @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md' 180 @Prop currentIndex: number 181 private index?:number 182 private icon?:Resource 183 private iconSelected?:Resource 184 private title?:Resource 185 186 private getIcon() { 187 return this.currentIndex === this.index ? this.iconSelected : this.icon 188 } 189 190 private getFontColor() { 191 return this.currentIndex === this.index ? TitleColor.SELECTED : TitleColor.NORMAL 192 } 193 194 build() { 195 if (this.currentBreakpoint !== 'md' ) { 196 Column() { 197 Image(this.getIcon()) 198 .width(24) 199 .height(24) 200 .margin(5) 201 .objectFit(ImageFit.Contain) 202 Text(this.title) 203 .fontColor(this.getFontColor()) 204 .fontSize(12) 205 .fontWeight(500) 206 }.justifyContent(FlexAlign.Center).height('100%').width('100%') 207 } else { 208 Row() { 209 Image(this.getIcon()) 210 .width(24) 211 .height(24) 212 .margin(5) 213 .objectFit(ImageFit.Contain) 214 Text(this.title) 215 .fontColor(this.getFontColor()) 216 .fontSize(12) 217 .fontWeight(500) 218 }.justifyContent(FlexAlign.Center).height('100%').width('100%') 219 } 220 } 221} 222``` 223 224 225## 标题栏与搜索栏 226 227标题栏和搜索栏,在sm和md断点下分两行显示,在lg断点下单行显示,可以通过栅格实现。在sm和md断点下,标题栏和搜索栏占满12列,此时会自动换行显示。在lg断点下,标题栏占8列而搜索栏占4列,此时标题栏和搜索栏在同一行中显示。 228 229 | | sm/md | lg | 230| -------- | -------- | -------- | 231| 效果图 |  |  | 232| 栅格布局图 |  |  | 233 234 235```ts 236@Component 237export default struct IndexHeader { 238 239 @Builder searchBar() { 240 Stack({alignContent: Alignment.End}) { 241 TextInput({ placeholder: $r('app.string.search') }) 242 .placeholderColor('#FF000000') 243 .placeholderFont({ size: 16, weight: 400 }) 244 .textAlign(TextAlign.Start) 245 .caretColor('#FF000000') 246 .width('100%') 247 .height(40) 248 .fontWeight(400) 249 .padding({ top: 9, bottom: 9 }) 250 .fontSize(16) 251 .backgroundColor(Color.White) 252 253 Image($r('app.media.ic_public_search')) 254 .width(16) 255 .height(16) 256 .margin({ right: 20 }) 257 }.height(56).width('100%') 258 } 259 260 @Builder titleBar() { 261 Text($r('app.string.tabBar1')) 262 .fontSize(24) 263 .fontWeight(500) 264 .fontColor('#18181A') 265 .textAlign(TextAlign.Start) 266 .height(56) 267 .width('100%') 268 } 269 270 build() { 271 // 借助栅格实现标题栏和搜索栏在不同断点下的不同布局效果。 272 GridRow() { 273 GridCol({ span: { xs: 12, lg: 8 } }) { 274 this.titleBar() 275 } 276 GridCol({ span: { xs: 12, lg: 4 } }) { 277 this.searchBar() 278 } 279 } 280 .width('100%') 281 } 282} 283``` 284 285 286## 运营横幅 287 288不同断点下的运营横幅,sm断点下显示一张图片,md断点下显示两张图片,lg断点下显示三张图片。可以通过[Swiper组件的displayCount属性](../../reference/apis-arkui/arkui-ts/ts-container-swiper.md)实现目标效果。 289 290 291```ts 292@Component 293export default struct IndexSwiper { 294 @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'; 295 @Builder swiperItem(imageSrc:Resource) { 296 Image(imageSrc) 297 .width('100%') 298 .aspectRatio(2.5) 299 .objectFit(ImageFit.Fill) 300 } 301 302 build() { 303 Swiper() { 304 this.swiperItem($r('app.media.ic_public_swiper1')) 305 this.swiperItem($r('app.media.ic_public_swiper2')) 306 this.swiperItem($r('app.media.ic_public_swiper3')) 307 // ... 308 } 309 .autoPlay(true) 310 .indicator(false) 311 .itemSpace(10) 312 // 配置不同断点下运行横幅中展示的图片数量 313 .displayCount(this.currentBreakpoint === 'sm' ? 1 : (this.currentBreakpoint === 'md' ? 2 : 3)) 314 .width('100%') 315 .padding({ left: 12, right: 12, bottom: 16, top: 16 }) 316 } 317} 318``` 319 320 321## 快捷入口 322 323在不同的断点下,快捷入口的5个图标始终均匀排布,这是典型的均分能力使用场景。图标资源文件[获取](https://gitee.com/openharmony/applications_app_samples/tree/master/code/SuperFeature/MultiDeviceAppDev/AppMarket/entry/src/main/resources) 324```ts 325// /model/HomeData 在resourse文件中放置以下资源文件, 326interface AppItem{ 327 id:string; 328 title:Resource; 329 image:Resource; 330} 331 332const appList:AppItem[] = [ 333 { id: '0', title: $r('app.string.public_app1'), image: $r('app.media.ic_public_app1') }, 334 { id: '1', title: $r('app.string.public_app2'), image: $r('app.media.ic_public_app2') }, 335 { id: '2', title: $r('app.string.public_app3'), image: $r('app.media.ic_public_app3') }, 336 { id: '3', title: $r('app.string.public_app4'), image: $r('app.media.ic_public_app4') }, 337 { id: '4', title: $r('app.string.public_app5'), image: $r('app.media.ic_public_app5') }, 338 { id: '5', title: $r('app.string.public_app6'), image: $r('app.media.ic_public_app6') }, 339 { id: '6', title: $r('app.string.public_app7'), image: $r('app.media.ic_public_app7') }, 340 { id: '7', title: $r('app.string.public_app8'), image: $r('app.media.ic_public_app8') }, 341 { id: '8', title: $r('app.string.public_app9'), image: $r('app.media.ic_public_app9') }, 342 { id: '9', title: $r('app.string.public_app10'), image: $r('app.media.ic_public_app10') }, 343 { id: '10', title: $r('app.string.public_app1'), image: $r('app.media.ic_public_app1') }, 344 { id: '11', title: $r('app.string.public_app1'), image: $r('app.media.ic_public_app1') }, 345 { id: '12', title: $r('app.string.public_app2'), image: $r('app.media.ic_public_app2') }, 346 { id: '13', title: $r('app.string.public_app3'), image: $r('app.media.ic_public_app3') }, 347 { id: '14', title: $r('app.string.public_app4'), image: $r('app.media.ic_public_app4') }, 348 { id: '15', title: $r('app.string.public_app5'), image: $r('app.media.ic_public_app5') }, 349 { id: '16', title: $r('app.string.public_app6'), image: $r('app.media.ic_public_app6') }, 350 { id: '17', title: $r('app.string.public_app7'), image: $r('app.media.ic_public_app7') }, 351 { id: '18', title: $r('app.string.public_app8'), image: $r('app.media.ic_public_app8') }, 352 { id: '19', title: $r('app.string.public_app9'), image: $r('app.media.ic_public_app9') }, 353 { id: '20', title: $r('app.string.public_app10'), image: $r('app.media.ic_public_app10') } 354] 355 356const gameList:AppItem[] = [ 357 { id: '21', title: $r('app.string.public_game1'), image: $r('app.media.ic_public_game1') }, 358 { id: '22', title: $r('app.string.public_game2'), image: $r('app.media.ic_public_game2') }, 359 { id: '23', title: $r('app.string.public_game3'), image: $r('app.media.ic_public_game3') }, 360 { id: '24', title: $r('app.string.public_game4'), image: $r('app.media.ic_public_game4') }, 361 { id: '25', title: $r('app.string.public_game5'), image: $r('app.media.ic_public_game5') }, 362 { id: '26', title: $r('app.string.public_game6'), image: $r('app.media.ic_public_game6') }, 363 { id: '27', title: $r('app.string.public_game7'), image: $r('app.media.ic_public_game7') }, 364 { id: '28', title: $r('app.string.public_game8'), image: $r('app.media.ic_public_game8') }, 365 { id: '29', title: $r('app.string.public_game9'), image: $r('app.media.ic_public_game9') }, 366 { id: '30', title: $r('app.string.public_game10'), image: $r('app.media.ic_public_game10') }, 367 { id: '31', title: $r('app.string.public_game1'), image: $r('app.media.ic_public_game1') }, 368 { id: '32', title: $r('app.string.public_game2'), image: $r('app.media.ic_public_game2') }, 369 { id: '33', title: $r('app.string.public_game3'), image: $r('app.media.ic_public_game3') }, 370 { id: '34', title: $r('app.string.public_game4'), image: $r('app.media.ic_public_game4') }, 371 { id: '35', title: $r('app.string.public_game5'), image: $r('app.media.ic_public_game5') }, 372 { id: '36', title: $r('app.string.public_game6'), image: $r('app.media.ic_public_game6') }, 373 { id: '37', title: $r('app.string.public_game7'), image: $r('app.media.ic_public_game7') }, 374 { id: '38', title: $r('app.string.public_game8'), image: $r('app.media.ic_public_game8') }, 375 { id: '39', title: $r('app.string.public_game9'), image: $r('app.media.ic_public_game9') }, 376 { id: '40', title: $r('app.string.public_game10'), image: $r('app.media.ic_public_game10') } 377] 378 379const entranceIcons:AppItem[]= [ 380 { id: '41',title: $r('app.string.home_categories'), image: $r('app.media.ic_home_categories') }, 381 { id: '42',title: $r('app.string.home_top'), image: $r('app.media.ic_home_top') }, 382 { id: '43',title: $r('app.string.home_fast'), image: $r('app.media.ic_home_fast') }, 383 { id: '44',title: $r('app.string.home_flower'), image: $r('app.media.ic_home_flower') }, 384 { id: '45',title: $r('app.string.home_education'), image: $r('app.media.ic_home_education') }, 385] 386 387export { entranceIcons, appList, gameList } 388``` 389 390 391 392 393```ts 394//model/HomeDataType 395interface AllIcons { 396 image: Resource, 397 title: Resource, 398} 399 400interface AppItem { 401 id: string, 402 title: Resource, 403 image: Resource 404} 405 406class MyAppSource implements IDataSource { 407 private dataArray: AppItem[] = [] 408 private listeners: DataChangeListener[] = [] 409 410 constructor(element: AppItem[]) { 411 for (let index = 0; index < element.length; index++) { 412 this.dataArray.push(element[index]) 413 } 414 } 415 416 public totalCount(): number { 417 return this.dataArray.length 418 } 419 420 public getData(index: number): AppItem { 421 return this.dataArray[index] 422 } 423 424 public addData(index: number, data: AppItem): void { 425 this.dataArray.splice(index, 0, data) 426 this.notifyDataAdd(index) 427 } 428 429 public pushData(data: AppItem): void { 430 this.dataArray.push(data) 431 this.notifyDataAdd(this.dataArray.length - 1) 432 } 433 434 registerDataChangeListener(listener: DataChangeListener): void { 435 if (this.listeners.indexOf(listener) < 0) { 436 this.listeners.push(listener) 437 } 438 } 439 440 unregisterDataChangeListener(listener: DataChangeListener): void { 441 const pos = this.listeners.indexOf(listener); 442 if (pos >= 0) { 443 this.listeners.splice(pos, 1) 444 } 445 } 446 447 notifyDataReload(): void { 448 this.listeners.forEach(listener => { 449 listener.onDataReloaded() 450 }) 451 } 452 453 notifyDataAdd(index: number): void { 454 this.listeners.forEach(listener => { 455 listener.onDataAdd(index) 456 }) 457 } 458 459 notifyDataChange(index: number): void { 460 this.listeners.forEach(listener => { 461 listener.onDataChange(index) 462 }) 463 } 464 465 notifyDataDelete(index: number): void { 466 this.listeners.forEach(listener => { 467 listener.onDataDelete(index) 468 }) 469 } 470 471 notifyDataMove(from: number, to: number): void { 472 this.listeners.forEach(listener => { 473 listener.onDataMove(from, to) 474 }) 475 } 476} 477 478export { AllIcons, MyAppSource, AppItem } 479 480``` 481 482```ts 483import { entranceIcons } from '../model/HomeData'; 484import { AllIcons } from '../model/HomeDataType'; 485 486@Component 487export default struct IndexEntrance { 488 build() { 489 // 将justifyContent参数配置为FlexAlign.SpaceEvenly实现均分布局 490 Row() { 491 ForEach(entranceIcons, (icon: AllIcons) => { 492 // 各快捷入口的图标及名称 493 Column() { 494 // ... 495 } 496 }) 497 } 498 .width('100%') 499 .height(64) 500 .justifyContent(FlexAlign.SpaceEvenly) 501 .padding({ left: 12, right: 12 }) 502 } 503} 504``` 505 506 507## 精品应用 508 509随着可用显示区域的增加,精品应用中显示的图标数量也不断增加,这是典型的延伸能力使用场景。精品游戏的实现与精品应用类似,不再展开分析。 510 511 512```ts 513import { AppItem, MyAppSource } from '../model/HomeDataType'; 514 515@Component 516export default struct IndexApps { 517 private title?: Resource; 518 @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'; 519 private apps: AppItem[] = []; 520 @Builder 521 appListHeader() { 522 Row() { 523 Text(this.title) 524 .width(100) 525 .fontSize(16) 526 .textAlign(TextAlign.Start) 527 .fontWeight(500) 528 Blank() 529 Text($r('app.string.more')) 530 .fontSize(14) 531 .textAlign(TextAlign.End) 532 .fontWeight(400) 533 .margin({ right: 2 }) 534 Image($r('app.media.ic_public_arrow_right')) 535 .width(12) 536 .height(18) 537 .opacity(0.9) 538 .objectFit(ImageFit.Fill) 539 } 540 .margin({ bottom: 9, top: 9 }) 541 .width('100%') 542 .alignItems(VerticalAlign.Bottom) 543 } 544 545 @Builder 546 appListItem(app:AppItem) { 547 Column() { 548 Image(app.image) 549 .width(this.currentBreakpoint === 'lg' ? 80 : 56) 550 .height(this.currentBreakpoint === 'lg' ? 80 : 56) 551 .margin({ bottom: 8 }) 552 Text(app.title) 553 .width(this.currentBreakpoint === 'lg' ? 80 : 56) 554 .height(16) 555 .fontSize(12) 556 .textAlign(TextAlign.Center) 557 .fontColor('#18181A') 558 .margin({ bottom: 8 }) 559 Text($r('app.string.install')) 560 .width(this.currentBreakpoint === 'lg' ? 80 : 56) 561 .height(28) 562 .fontColor('#0A59F7') 563 .textAlign(TextAlign.Center) 564 .borderRadius(this.currentBreakpoint === 'lg' ? 26 : 20) 565 .fontWeight(500) 566 .fontSize(12) 567 .padding({ top: 6, bottom: 6, left: 8, right: 8 }) 568 .backgroundColor('rgba(0,0,0,0.05)') 569 } 570 } 571 build() { 572 Column() { 573 this.appListHeader() 574 // 借助List组件能力,实现延伸能力场景 575 List({ space: this.currentBreakpoint === 'lg' ? 44 : 20}) { 576 LazyForEach(new MyAppSource(this.apps), (app: AppItem)=> { 577 ListItem() { 578 // 每个应用的图标、名称及安装按钮 579 this.appListItem(app) 580 } 581 }) 582 } 583 .width('100%') 584 .height(this.currentBreakpoint === 'lg' ? 140 : 120) 585 .listDirection(Axis.Horizontal) 586 } 587 .width('100%') 588 .height(this.currentBreakpoint === 'lg' ? 188 : 164) 589 .padding({ bottom: 8, left: 12, right: 12 }) 590 } 591} 592``` 593 594 595## 运行效果 596 597将上述各页面主要部分组合在一起后,即可完成整体页面开发。 598 599 600```ts 601entry/src/main/ets // 代码区 602|---model 603| |---HomeData.ets // 主页用到的图片资源 604| |---HomeDataType.ets // 事件监听函数 605|---pages 606| |---index.ets // 首页 607|---common 608| |---BreakpointSystem.ets // 媒体查询 609| |---Home.ets // 主容器 610| |---IndexApps.ets // app模块(包含安装,展示图片,更多功能) 611| |---IndexContent.ets // 内容模块 612| |---IndexEntrance.ets // 下一步模块(箭头跳转组件) 613| |---IndexHeader.ets // 头部组件 614| |---IndexSwiper.ets // 轮播图 615| |---TabBarItem.ets // 导航栏 616entry/src/main/resources // 资源文件 617``` 618 619```ts 620import IndexSwiper from './IndexSwiper'; 621import IndexEntrance from './IndexEntrance'; 622import IndexApps from './IndexApps'; 623import { appList, gameList } from '../model/HomeData'; 624import IndexHeader from './IndexHeader'; 625 626@Component 627struct IndexContent { 628 // ... 629 build() { 630 List() { 631 // 运营横幅 632 ListItem() { 633 IndexSwiper() 634 } 635 // 快捷入口 636 ListItem() { 637 IndexEntrance() 638 } 639 // 精品应用 640 ListItem() { 641 IndexApps({ title: $r('app.string.boutique_application'), apps: appList }) 642 } 643 // 精品游戏 644 ListItem() { 645 IndexApps({ title: $r('app.string.boutique_game'), apps: gameList }) 646 } 647 } 648 .width("100%") 649 } 650} 651 652@Entry 653@Component 654export default struct Home { 655 // ... 656 build() { 657 Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Start, alignItems: ItemAlign.Start }) { 658 // 标题栏和搜索栏 659 IndexHeader() 660 // 运营横幅、快捷入口、精品应用、精品游戏等 661 IndexContent() 662 } 663 .height('100%') 664 .backgroundColor("#F1F3F5") 665 } 666} 667``` 668 669本页面的实际运行效果如下图所示。 670 671 | sm | md | lg | 672| -------- | -------- | -------- | 673|  |  |  | 674 675## 相关实例 676 677针对应用市场应用开发,有以下相关实例可以参考: 678 679- 应用市场开发:[典型页面场景:应用市场首页(ArkTS)(API9)](https://gitee.com/openharmony/applications_app_samples/tree/master/code/SuperFeature/MultiDeviceAppDev/AppMarket) 680 681 682 683<!--no_check-->