找回密码
 立即注册
首页 业界区 业界 使用Tabs选项卡组件快速搭建鸿蒙APP框架

使用Tabs选项卡组件快速搭建鸿蒙APP框架

羊舌正清 2025-9-27 07:26:40
大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!
ArkUI提供了很多布局组件,其中Tabs选项卡组件可以用于快速搭建鸿蒙APP框架,本文通过案例研究Tabs构建鸿蒙原生应用框架的方法和步骤。
一、效果展示

1、效果展示

1.png

整个APP外层Tabs包含4个选项卡:首页、发现、消息、我的。在首页中,上滑列表会出现吸顶效果,分类可以左右滑动,当滑到最后一个分类时,与外层Tabs联动,滑到“发现”页面。首页中的分类标签可以用户自定义选择显示。
2、技术分析

主要使用Tabs选项卡搭建整个APP的框架,通过设置Tabs相关的属性和方法实现布局、滚动、吸顶、内外层嵌套联动等功能。
Tabs组件的页面组成包含两个部分,分别是TabContent和TabBar。TabContent是内容页,TabBar是导航页签栏,,根据不同的导航类型,布局会有区别,可以分为底部导航、顶部导航、侧边导航,其导航栏分别位于底部、顶部和侧边。
本例中通过嵌套Tabs实现,外层Tabs为底部导航、内层Tabs为顶部导航。
二、功能实现

1、准备工作

1.1 数据准备

在商业项目中,界面显示的数据是通过网络请求后端接口获得,本例重点放在Tabs组件的用法研究上,因此简化数据获取过程,直接将数据写入到json文件中。
将准备好的界面数据文件(tab标签和数据列表)拷贝到resources/rawfile目录下包含4个文件:default_all_tabs.json、default_all_tabs_en.json、default_content_items.json、default_content_items_en.json。
1.2 本地化

将界面文字
zh_CN/element:integer.json、string.json
en_US/element:integer.json、string.json
base/element:integer.json、string.json、color.json
1.3 素材

base/media:图片素材
1.4 通用类

ets目录新建common目录,新建constat目录用于存放常量,新建utils目录用于存放工具类。
constant目录下新建Constants.ets文件,记录用到的常量。
  1. export class Constants {
  2.   /**
  3.    * Full screen width.
  4.    */
  5.   static readonly FULL_WIDTH: string = '100%';
  6.   /**
  7.    * Full screen height.
  8.    */
  9.   static readonly FULL_HEIGHT: string = '100%';
  10. }
复制代码
utils目录下新建StringUtil.ets文件,用于处理从文件中读取的数据。
  1. import { util } from "@kit.ArkTS";
  2. import { BusinessError } from "@kit.BasicServicesKit";
  3. import { hilog } from "@kit.PerformanceAnalysisKit";
  4. export default class StringUtil {
  5.   static async getStringFromRawFile(ctx: Context, source: string) {
  6.     try {
  7.       let getJson = await ctx.resourceManager.getRawFileContent(source);
  8.       let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
  9.       let result = textDecoder.decodeToString(getJson);
  10.       return Promise.resolve(result);
  11.     } catch (error) {
  12.       let code = (error as BusinessError).code;
  13.       let message = (error as BusinessError).message;
  14.       hilog.error(0x0000, 'StringUtil', 'getStringSync failed,error code: %{code}s,message: %{message}s.', code,
  15.         message);
  16.       return Promise.reject(error);
  17.     }
  18.   }
  19. }
复制代码
2、整体框架

整体布局分为2部分,顶部搜索栏和其下的嵌套Tabs页面。为了提升可维护性,采用组件化编程思想。
2.1 搜索组件

在ets目录下新建view目录用于存放组件,新建搜索组件SearchBarComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. @Component
  3. export default struct SearchBarComponent {
  4.   @State changeValue: string = '';
  5.   build() {
  6.     Row() {
  7.       // 1、传统方法
  8.       // Stack() {
  9.       //   TextInput({ placeholder: $r('app.string.search_placeholder') })
  10.       //     .height(40)
  11.       //     .width(Constants.FULL_WIDTH)
  12.       //     .fontSize(16)
  13.       //     .placeholderColor(Color.Grey)
  14.       //     .placeholderFont({ size: 16, weight: FontWeight.Normal })
  15.       //     .borderStyle(BorderStyle.Solid)
  16.       //     .backgroundColor($r('app.color.search_bar_input_color'))
  17.       //     .padding({ left: 35, right: 66 })
  18.       //     .onChange((currentContent) => {
  19.       //       this.changeValue = currentContent;
  20.       //     })
  21.       //   Row() {
  22.       //     Image($r('app.media.ic_search')).width(20).height(20)
  23.       //     Button($r('app.string.search'))
  24.       //       .padding({ left: 20, right: 20 })
  25.       //       .height(36)
  26.       //       .fontColor($r('app.color.search_bar_button_color'))
  27.       //       .fontSize(16)
  28.       //       .backgroundColor($r('app.color.search_bar_input_color'))
  29.       //
  30.       //   }.width(Constants.FULL_WIDTH)
  31.       //   .hitTestBehavior(HitTestMode.None)
  32.       //   .justifyContent(FlexAlign.SpaceBetween)
  33.       //   .padding({ left: 10, right: 2 })
  34.       // }.alignContent(Alignment.Start)
  35.       // .width(Constants.FULL_WIDTH)
  36.       // 2、搜索组件
  37.       Search({placeholder:$r('app.string.search_placeholder')})
  38.         .searchButton('搜索')
  39.     }
  40.     .justifyContent(FlexAlign.SpaceBetween)
  41.     .padding(10)
  42.     .width(Constants.FULL_WIDTH)
  43.     .backgroundColor($r('app.color.out_tab_bar_background_color'))
  44.     .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])
  45.   }
  46. }
复制代码
在主界面引入,即可查看效果。修改Index.ets
  1. import { Constants } from '../common/constant/Constants';
  2. import SearchBarComponent from '../view/SearchBarComponent';
  3. @Entry
  4. @Component
  5. struct Index {
  6.   build() {
  7.     Column() {
  8.       // 搜索栏
  9.       SearchBarComponent()
  10.     }
  11.     .height(Constants.FULL_HEIGHT)
  12.     .width(Constants.FULL_WIDTH)
  13.     .expandSafeArea([SafeAreaType.SYSTEM])
  14.   }
  15. }
复制代码
2.2 外层Tabs

通过界面分析,外层Tabs的每一个TabContent内容不同,可以抽取为组件。第一个TabContent抽取为组件InTabsComponent,后边的几个抽取为OtherTabContentComponent。
在view目录下新建组件:InTabsComponent.ets
  1. @Component
  2. export default struct InTabsComponent {
  3.   build() {
  4.     Text('内层Tabs')
  5.   }
  6. }
复制代码
在InTabsComponent中,先简单写点提示信息,待整体框架完成后,后续再继续完成内层的内容。
在view目录下新建组件:OtherTabComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. @Component
  3. export default struct OtherTabContentComponent {
  4.   @State bgColor: ResourceColor = $r('app.color.other_tab_content_default_color');
  5.   build() {
  6.     Column()
  7.       .width(Constants.FULL_WIDTH)
  8.       .height(Constants.FULL_HEIGHT)
  9.       .backgroundColor(this.bgColor)
  10.   }
  11. }
复制代码
在OtherTabComponent中,通过接收父组件传递的颜色参数来设置背景颜色,用以区分不同的Tab。
在view目录下,新建外层组件OutTabsComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. import InTabsComponent from "./InTabsComponent";
  3. import OtherTabContentComponent from "./OtherTabComponent";
  4. @Component
  5. export default struct OutTabsComponent {
  6.   @State currentIndex: number = 0;
  7.   private tabsController: TabsController = new TabsController();
  8.   @Builder
  9.   tabBuilder(index: number, name: string | Resource, icon: Resource) {
  10.     Column() {
  11.       SymbolGlyph(icon).fontColor([this.currentIndex === index
  12.         ? $r('app.color.out_tab_bar_font_active_color')
  13.         : $r('app.color.out_tab_bar_font_inactive_color')])
  14.         .fontSize(25)
  15.       Text(name)
  16.         .margin({ top: 4 })
  17.         .fontSize(10)
  18.         .fontColor(this.currentIndex === index
  19.           ? $r('app.color.out_tab_bar_font_active_color')
  20.           : $r('app.color.out_tab_bar_font_inactive_color'))
  21.     }
  22.     .justifyContent(FlexAlign.Center)
  23.     .height(Constants.FULL_HEIGHT)
  24.     .width(Constants.FULL_WIDTH)
  25.     .padding({ bottom: 60 })
  26.   }
  27.   build() {
  28.     Tabs({
  29.       barPosition: BarPosition.End,
  30.       index: this.currentIndex,
  31.       controller: this.tabsController,
  32.     }) {
  33.       TabContent() {
  34.         InTabsComponent()
  35.       }.tabBar(this.tabBuilder(0, $r('app.string.out_bar_text_home'), $r('sys.symbol.house')))
  36.       TabContent() {
  37.         OtherTabContentComponent({ bgColor: Color.Blue })
  38.       }
  39.       .tabBar(this.tabBuilder(1, $r('app.string.out_bar_text_discover'), $r('sys.symbol.map_badge_local')))
  40.       TabContent() {
  41.         OtherTabContentComponent({ bgColor: Color.Yellow })
  42.       }
  43.       .tabBar(this.tabBuilder(2, $r('app.string.out_bar_text_messages'), $r('sys.symbol.ellipsis_message')))
  44.       TabContent() {
  45.         OtherTabContentComponent({ bgColor: Color.Orange })
  46.       }
  47.       .tabBar(this.tabBuilder(3, $r('app.string.out_bar_text_profile'), $r('sys.symbol.person')))
  48.     }
  49.     .vertical(false)
  50.     .barMode(BarMode.Fixed)
  51.     .scrollable(true) // false to disable scroll to switch
  52.     // .edgeEffect(EdgeEffect.None) // disables edge springback
  53.     .onChange((index: number) => {
  54.       this.currentIndex = index;
  55.     })
  56.     .height(Constants.FULL_HEIGHT)
  57.     .width(Constants.FULL_WIDTH)
  58.     .backgroundColor($r('app.color.out_tab_bar_background_color'))
  59.     .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  60.     .barHeight(120)
  61.     .barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK)
  62.     .barOverlap(true)
  63.   }
  64. }
复制代码
在主界面中引入外层Tabs组件OutTabsComponent,修改主界面Index.ets
  1. import OutTabsComponent from '../view/OutTabsComponent';
  2. ...
  3.     // 外层tabs
  4.     OutTabsComponent()
复制代码
这样就实现了整体布局。
3、内层组件

分析内层组件布局结构,顶部是一张Banner图片,下边是一个Tabs组件。整个内层组件可以上下滚动,并且上滑要产生吸顶效果,因此外层组件应该使用Scroll滚动组件作为顶层父容器,里边滚动的内容使用List组件即可,List里边的内容也需要封装成组件。
3.1 Banner组件

接下来先封装顶部的Banner图片组件,在view目录下新建BannerComponent组件,BannerComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. @Component
  3. export default struct BannerComponent {
  4.   build() {
  5.     Column() {
  6.       Image($r('app.media.pic5'))
  7.         .width(Constants.FULL_WIDTH)
  8.         .height(186)
  9.         .borderRadius(16)
  10.     }
  11.     .margin({
  12.       left: 5,
  13.       right: 5,
  14.       top: 10,
  15.       bottom: 2
  16.     })
  17.   }
  18. }
复制代码
3.2 列表项组件

接下来封装列表项组件ContentItemComponent,
封装数据类ContentItemModel,在ets目录下新建model目录,新建ContentItemModel.ets
  1. export default class ContentItemModel {
  2.   username: string | Resource = '';
  3.   publishTime: string | Resource = '';
  4.   rawTitle: string | Resource = '';
  5.   title: string | Resource = '';
  6.   imgUrl1: string | Resource = '';
  7.   imgUrl2: string | Resource = '';
  8.   imgUrl3: string | Resource = '';
  9.   imgUrl4: string | Resource = '';
  10. }
复制代码
封装数据类ContentItemViewModel,在ets目录下新建viewmodel目录,新建ContentItemViewModel.ets文件
  1. import ContentItemModel from "../model/ContentItemModel";
  2. @Observed
  3. export default class ContentItemViewModel {
  4.   username: string | Resource = '';
  5.   publishTime: string | Resource = '';
  6.   rawTitle: string | Resource = '';
  7.   title: string | Resource = '';
  8.   imgUrl1: string | Resource = '';
  9.   imgUrl2: string | Resource = '';
  10.   imgUrl3: string | Resource = '';
  11.   imgUrl4: string | Resource = '';
  12.   updateContentItem(contentItemModel: ContentItemModel) {
  13.     this.username = contentItemModel.username;
  14.     this.publishTime = contentItemModel.publishTime;
  15.     this.rawTitle = contentItemModel.rawTitle;
  16.     this.title = contentItemModel.title;
  17.     this.imgUrl1 = contentItemModel.imgUrl1;
  18.     this.imgUrl2 = contentItemModel.imgUrl2;
  19.     this.imgUrl3 = contentItemModel.imgUrl3;
  20.     this.imgUrl4 = contentItemModel.imgUrl4;
  21.   }
  22. }
复制代码
在view目录新建ContentItemComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. import ContentItemViewModel from "../viewmodel/ContentItemViewModel";
  3. @Component
  4. export default struct ContentItemComponent {
  5.   @Prop contentItemViewModel: ContentItemViewModel;
  6.   build() {
  7.     Column() {
  8.       Row() {
  9.         Image(this.contentItemViewModel.imgUrl1)
  10.           .width(30)
  11.           .height(30)
  12.           .borderRadius(15)
  13.         Column() {
  14.           Text(this.contentItemViewModel.username)
  15.             .fontSize(15)
  16.           Text(this.contentItemViewModel.publishTime)
  17.             .fontSize(12)
  18.             .fontColor($r('app.color.content_item_text_color'))
  19.         }
  20.         .margin({ left: 10 })
  21.         .justifyContent(FlexAlign.Start)
  22.         .alignItems(HorizontalAlign.Start)
  23.       }
  24.       Column() {
  25.         Text(this.contentItemViewModel.title)
  26.           .fontSize(16)
  27.           .id('title')
  28.           .textAlign(TextAlign.Start)
  29.       }
  30.       .margin({top:10, bottom: 10})
  31.       Row() {
  32.         Image(this.contentItemViewModel.imgUrl2)
  33.           .width(115)
  34.           .height(115)
  35.         Image(this.contentItemViewModel.imgUrl3)
  36.           .width(115)
  37.           .height(115)
  38.         Image(this.contentItemViewModel.imgUrl4)
  39.           .width(115)
  40.           .height(115)
  41.       }
  42.       .width(Constants.FULL_WIDTH)
  43.       .justifyContent(FlexAlign.SpaceBetween)
  44.     }
  45.     .width(Constants.FULL_WIDTH)
  46.     .alignItems(HorizontalAlign.Start)
  47.   }
  48. }
复制代码
3.3 列表数据封装

在制作列表项组件时封装了每一项数据对应的类ContentItemModel,还需要封装一个类用于表示整个Tabs界面的数据。
在model目录下新建InTabsModel.ets
  1. import { BusinessError } from '@kit.BasicServicesKit';
  2. import { hilog } from '@kit.PerformanceAnalysisKit';
  3. import ContentItemModel from './ContentItemModel';
  4. import StringUtil from '../common/utils/StringUtil';
  5. export default class InTabsModel {
  6.   contentItems: ContentItemModel[] = [];
  7.   async loadContentItems(ctx: Context) {
  8.     let filename = '';
  9.     try {
  10.       filename = await ctx.resourceManager.getStringValue($r('app.string.default_content_items_file').id);
  11.     } catch (error) {
  12.       let err = error as BusinessError;
  13.       hilog.error(0x0000, 'InTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);
  14.     }
  15.     let res = await StringUtil.getStringFromRawFile(ctx, filename);
  16.     this.contentItems = JSON.parse(res).map((item: ContentItemModel) => {
  17.       let img1 = item.imgUrl1 as string;
  18.       if (img1.indexOf('app.media') === 0) {
  19.         item.imgUrl1 = $r(img1);
  20.       }
  21.       let img2 = item.imgUrl2 as string;
  22.       if (img2.indexOf('app.media') === 0) {
  23.         item.imgUrl2 = $r(img2);
  24.       }
  25.       let img3 = item.imgUrl3 as string;
  26.       if (img3.indexOf('app.media') === 0) {
  27.         item.imgUrl3 = $r(img3);
  28.       }
  29.       let img4 = item.imgUrl4 as string;
  30.       if (img4.indexOf('app.media') === 0) {
  31.         item.imgUrl4 = $r(img4);
  32.       }
  33.       return item;
  34.     });
  35.   }
  36. }
复制代码
该类主要实现从本地文件中读取列表数据。
在viewmodel目录下新建文件InTabsViewModel.ets
  1. import ContentItemViewModel from "./ContentItemViewModel";
  2. import InTabsModel from "../model/InTabsModel";
  3. @Observed
  4. class ContentItemArray extends Array<ContentItemViewModel> {
  5. }
  6. @Observed
  7. export default class InTabsViewModel {
  8.   private inTabsModel: InTabsModel = new InTabsModel();
  9.   contentItems: ContentItemArray = new ContentItemArray();
  10.   async loadContentData(ctx: Context) {
  11.     await this.inTabsModel.loadContentItems(ctx);
  12.     let tempItems: ContentItemArray = [];
  13.     for (let item of this.inTabsModel.contentItems) {
  14.       let contentItemViewModel = new ContentItemViewModel();
  15.       contentItemViewModel.updateContentItem(item);
  16.       tempItems.push(contentItemViewModel);
  17.     }
  18.     this.contentItems = tempItems;
  19.   }
  20. }
复制代码
3.4 Tab类封装

将每一个Tab抽象为TabItemModel类,以便于记录当前选中的选项卡。
在model目录下新建TabItemModel.ets
  1. export default class TabItemModel {
  2.   id: number = 0;
  3.   name: string | Resource = '';
  4.   isChecked: boolean = true;
  5. }
复制代码
在viewmodel目录下新建TabItemViewModel.ets
  1. import TabItemModel from "../model/TabItemModel";
  2. @Observed
  3. export default class TabItemViewModel {
  4.   id: number = 0;
  5.   name: string | Resource = '';
  6.   isChecked: boolean = true;
  7.   updateTab(tabItemModel: TabItemModel) {
  8.     this.id = tabItemModel.id;
  9.     this.name = tabItemModel.name;
  10.     this.isChecked = tabItemModel.isChecked;
  11.   }
  12. }
复制代码
3.5 标签分类封装

内层Tabs的标签TarBar也是直接从文件读取,内层标签初始加载时直接读取文件内容进行显示,后续还需要添加分类的选择和取消功能,实现自定义显示分类。
本小节先封装相关类,在model目录下新建SelectTabsModel类,用于存取文件中的标签分类,SelectTabsModel.ets
  1. import { BusinessError } from '@kit.BasicServicesKit';
  2. import { hilog } from '@kit.PerformanceAnalysisKit';
  3. import TabItemModel from './TabItemModel';
  4. import StringUtil from '../common/utils/StringUtil';
  5. export default class SelectTabsModel {
  6.   allTabs: TabItemModel[] = [];
  7.   async loadAllTabs(ctx: Context) {
  8.     let filename = '';
  9.     try {
  10.       filename = await ctx.resourceManager.getStringValue($r('app.string.default_all_tabs_file').id);
  11.     } catch (error) {
  12.       let err = error as BusinessError;
  13.       hilog.error(0x0000, 'SelectTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);
  14.     }
  15.     let result = await StringUtil.getStringFromRawFile(ctx, filename);
  16.     this.allTabs = JSON.parse(result);
  17.   }
  18. }
复制代码
在viewmodel目录下新建SelectTabsViewModel.ets
  1. import TabItemViewModel from "./TabItemViewModel";
  2. import SelectTabsModel from "../model/SelectTabsModel";
  3. @Observed
  4. class TabItemArray extends Array<TabItemViewModel> {
  5. }
  6. @Observed
  7. export default class SelectTabsViewModel {
  8.   allTabs: TabItemArray = new TabItemArray();
  9.   selectedTabs: TabItemArray = new TabItemArray();
  10.   private selectTabsModel: SelectTabsModel = new SelectTabsModel();
  11.   async loadTabs(ctx: Context) {
  12.     await this.selectTabsModel.loadAllTabs(ctx);
  13.     let tempTabs: TabItemViewModel[] = [];
  14.     for (let tab of this.selectTabsModel.allTabs) {
  15.       let tabItemViewModel = new TabItemViewModel();
  16.       tabItemViewModel.updateTab(tab);
  17.       tempTabs.push(tabItemViewModel);
  18.     }
  19.     this.allTabs = tempTabs;
  20.     this.updateSelectedTabs();
  21.   }
  22.   updateSelectedTabs() {
  23.     let tempTabs: TabItemViewModel[] = [];
  24.     for (let tab of this.allTabs) {
  25.       if (tab.isChecked) {
  26.         tempTabs.push(tab);
  27.       }
  28.     }
  29.     this.selectedTabs = tempTabs;
  30.   }
  31. }
复制代码
3.6 内层组件

修改InTabsComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. import BannerComponent from "./BannerComponent";
  3. import { CommonModifier } from "@kit.ArkUI";
  4. import ContentItemComponent from "./ContentItemComponent";
  5. import ContentItemViewModel from "../viewmodel/ContentItemViewModel";
  6. import TabItemViewModel from "../viewmodel/TabItemViewModel";
  7. import InTabsViewModel from "../viewmodel/InTabsViewModel";
  8. import { EnvironmentCallback, Configuration, AbilityConstant } from "@kit.AbilityKit";
  9. import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel";
  10. @Component
  11. export default struct InTabsComponent {
  12.   @State selectTabsViewModel: SelectTabsViewModel = new SelectTabsViewModel();
  13.   @State inTabsViewModel: InTabsViewModel = new InTabsViewModel();
  14.   @State tabBarModifier: CommonModifier = new CommonModifier();
  15.   @State focusIndex: number = 0;
  16.   @State showSelectTabsComponent: boolean = false;
  17.   @State selectTabsComponentZIndex: number = -1;
  18.   private ctx: Context = this.getUIContext().getHostContext() as Context;
  19.   private subsController: TabsController = new TabsController();
  20.   private tabBarItemScroller: Scroller = new Scroller();
  21.   subscribeSystemLanguageUpdate() {
  22.     let systemLanguage: string | undefined;
  23.     let inTabsViewModel = this.inTabsViewModel;
  24.     let selectTabsViewModel = this.selectTabsViewModel;
  25.     let applicationContext = this.ctx.getApplicationContext();
  26.     let environmentCallback: EnvironmentCallback = {
  27.       async onConfigurationUpdated(newConfig: Configuration) {
  28.         if (systemLanguage !== newConfig.language) {
  29.           await inTabsViewModel.loadContentData(applicationContext);
  30.           await selectTabsViewModel.loadTabs(applicationContext);
  31.           systemLanguage = newConfig.language;
  32.         }
  33.       },
  34.       onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
  35.         // do nothing
  36.       }
  37.     };
  38.     applicationContext.on('environment', environmentCallback);
  39.   }
  40.   async aboutToAppear() {
  41.     await this.inTabsViewModel.loadContentData(this.ctx);
  42.     await this.selectTabsViewModel.loadTabs(this.ctx);
  43.     this.tabBarModifier.margin({ right: 56 }).align(Alignment.Start);
  44.     this.subscribeSystemLanguageUpdate();
  45.   }
  46.   @Builder
  47.   tabBuilder(index: number, tab: TabItemViewModel) {
  48.     Row() {
  49.       Text(tab.name)
  50.         .fontSize(14)
  51.         .fontWeight(this.focusIndex === index ? FontWeight.Medium : FontWeight.Regular)
  52.         .fontColor(this.focusIndex === index ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
  53.     }
  54.     .justifyContent(FlexAlign.Center)
  55.     .backgroundColor(this.focusIndex === index
  56.       ? $r('app.color.in_tab_bar_background_active_color')
  57.       : $r('app.color.in_tab_bar_background_inactive_color'))
  58.     .borderRadius(20)
  59.     .height(40)
  60.     .margin({ left: 4, right: 4 })
  61.     .padding({ left: 18, right: 18 })
  62.     .onClick(() => {
  63.       this.focusIndex = index;
  64.       this.subsController.changeIndex(index);
  65.       this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);
  66.     })
  67.   }
  68.   build() {
  69.     Scroll() {
  70.       Column() {
  71.         BannerComponent()
  72.         Stack({ alignContent: Alignment.TopEnd }) {
  73.           Row() {
  74.             Image($r('app.media.more'))
  75.               .width(20)
  76.               .height(20)
  77.               .margin({ left: 10 })
  78.               .onClick(() => {
  79.                 // todo:弹层选择分类
  80.               })
  81.           }
  82.           .margin({ top: 8, bottom: 8, right: 5 })
  83.           .backgroundColor($r('app.color.in_tab_bar_background_inactive_color'))
  84.           .width(40)
  85.           .height(40)
  86.           .borderRadius(20)
  87.           .zIndex(1)
  88.           Column() {
  89.             Tabs({
  90.               barPosition: BarPosition.Start,
  91.               controller: this.subsController,
  92.               barModifier: this.tabBarModifier
  93.             }) {
  94.               ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
  95.                 TabContent() {
  96.                   List({ space: 10 }) {
  97.                     ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
  98.                       ContentItemComponent({
  99.                         contentItemViewModel: item,
  100.                       })
  101.                     }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
  102.                   }
  103.                   .padding({ left: 5, right: 5, bottom: 120 })
  104.                   .width(Constants.FULL_WIDTH)
  105.                   .height(Constants.FULL_HEIGHT)
  106.                   .scrollBar(BarState.Off)
  107.                 }
  108.                 .tabBar(this.tabBuilder(index, tab))
  109.               }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
  110.             }
  111.             .barMode(BarMode.Scrollable)
  112.             .width(Constants.FULL_WIDTH)
  113.             .height(Constants.FULL_HEIGHT)
  114.             .barBackgroundColor($r('app.color.out_tab_bar_background_color'))
  115.             .scrollable(true)
  116.             .onChange((index: number) => {
  117.               this.focusIndex = index;
  118.               this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);
  119.               let preloadItems: number[] = [];
  120.               if (index - 1 >= 0) {
  121.                 preloadItems.push(index - 1);
  122.               }
  123.               if (index + 1 < this.selectTabsViewModel.selectedTabs.length) {
  124.                 preloadItems.push(index + 1);
  125.               }
  126.               this.subsController.preloadItems(preloadItems);
  127.             })
  128.           }
  129.           .width(Constants.FULL_WIDTH)
  130.           .height(Constants.FULL_HEIGHT)
  131.           .backgroundColor($r('app.color.out_tab_bar_background_color'))
  132.         }
  133.       }
  134.     }
  135.     .scrollBar(BarState.Off)
  136.     .width(Constants.FULL_WIDTH)
  137.     .height(Constants.FULL_HEIGHT)
  138.     .backgroundColor($r('app.color.out_tab_bar_background_color'))
  139.     .padding({ left: 5, right: 5 })
  140.   }
  141. }
复制代码
这样基本效果就实现了。
3.7 吸顶效果

Tabs父组件外及Tabs的TabContent组件内嵌套可滑动组件。在TabContent内可滑动组件上设置滑动行为属性nestedScroll,使其往上滑动时,父组件先动,往下滑动时自己先动。
修改InTabsComponent,为List组件添加nestedScroll属性
  1. ...
  2. List(){
  3.     ...
  4. }
  5. .nestedScroll({
  6.     scrollForward: NestedScrollMode.PARENT_FIRST,
  7.     scrollBackward: NestedScrollMode.SELF_FIRST
  8. })
  9. ...
复制代码
3.8 内外联动

当滑动内层Tabs最后一个时,需要联动外层滚动。
实现思路:外层Tabs和内层Tabs均可滑动切换页签,内层滑到尽头触发外层滑动;在内层Tabs最后一个TabContent上监听滑动手势,通过@Link传递变量到父组件的外层Tabs,然后通过外层Tabs的TabController控制其滑动。
在InTabsComponent组件中,通过ForEach遍历生成TabContent时,需要给最后一项绑定 滚动手势,设置当前是最后一项的标识。InTabsComponent.ets
  1. @Link switchNext: boolean; //是否内层Tab最后一项
  2. ...
  3. Tabs(){
  4.    ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
  5.      if (index === this.selectTabsViewModel.selectedTabs.length - 1) {
  6.           TabContent() {
  7.                 List({ space: 10 }) {
  8.                     ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
  9.                         ContentItemComponent({
  10.                           contentItemViewModel: item,
  11.                         })
  12.                       }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
  13.                     }
  14.                     .padding({ left: 5, right: 5, bottom: 120 })
  15.                     .width(Constants.FULL_WIDTH)
  16.                     .height(Constants.FULL_HEIGHT)
  17.                     .scrollBar(BarState.Off)
  18.                     .nestedScroll({
  19.                       scrollForward: NestedScrollMode.PARENT_FIRST,
  20.                       scrollBackward: NestedScrollMode.SELF_FIRST
  21.                     })
  22.                   }
  23.                   .tabBar(this.tabBuilder(index, tab))
  24.                   .gesture(PanGesture(new PanGestureOptions({ direction: PanDirection.Left })).onActionStart(() => {
  25.                     this.switchNext = true;
  26.                   }))
  27.         }else {
  28.                   TabContent() {
  29.                     List({ space: 10 }) {
  30.                       ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {
  31.                         ContentItemComponent({
  32.                           contentItemViewModel: item,
  33.                         })
  34.                       }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))
  35.                     }
  36.                     .padding({ left: 5, right: 5, bottom: 120 })
  37.                     .width(Constants.FULL_WIDTH)
  38.                     .height(Constants.FULL_HEIGHT)
  39.                     .scrollBar(BarState.Off)
  40.                     .nestedScroll({
  41.                       scrollForward: NestedScrollMode.PARENT_FIRST,
  42.                       scrollBackward: NestedScrollMode.SELF_FIRST
  43.                     })
  44.                   }
  45.                   .tabBar(this.tabBuilder(index, tab))
  46.                 }
  47.               }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
  48.             }
  49. }
复制代码
外层组件OutTabsComponent传递参数,并监听该参数,一旦子组件回传的参数改变,则调用外层Tabs的控制器来改变外层Tab选择项,选中下一页。
  1. @State @Watch('onchangeSwitchNext') switchNext: boolean = false;
  2.   onchangeSwitchNext() {
  3.     if (this.switchNext) {
  4.       this.switchNext = false;
  5.       this.tabsController.changeIndex(1);
  6.     }
  7.   }
  8. TabContent() {
  9.      InTabsComponent({ switchNext: this.switchNext })
  10. }
复制代码
这样就实现了内层组件与外层组件联动。
3.9 分类选择

在首页中,分类可以由用户自定义选择,点击图片弹出组件InTabsModel。
制作选择分类组件SelectTabsComponent,在view目录下新建SelectTabsComponent.ets
  1. import { Constants } from "../common/constant/Constants";
  2. import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel"
  3. import TabItemViewModel from "../viewmodel/TabItemViewModel";
  4. @Component
  5. export default struct SelectTabsComponent {
  6.   @State checkedChange: boolean = false;
  7.   @Link selectTabsViewModel: SelectTabsViewModel;
  8.   build() {
  9.     Grid() {
  10.       ForEach(this.selectTabsViewModel.allTabs, (tab: TabItemViewModel) => {
  11.         GridItem() {
  12.           Row() {
  13.             Toggle({ type: ToggleType.Button, isOn: tab.isChecked }) {
  14.               if (this.checkedChange) {
  15.                 Text(tab.name)
  16.                   .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
  17.                   .fontSize(14)
  18.               } else {
  19.                 Text(tab.name)
  20.                   .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))
  21.                   .fontSize(14)
  22.               }
  23.             }
  24.             .width($r('app.integer.in_tab_bar_width'))
  25.             .borderRadius(20)
  26.             .height(40)
  27.             .margin({
  28.               left: 4,
  29.               right: 4,
  30.               top: 10,
  31.               bottom: 10
  32.             })
  33.             .padding({ left: 12, right: 12 })
  34.             .selectedColor($r('app.color.in_tab_bar_background_active_color'))
  35.             .onChange((isOn: boolean) => {
  36.               tab.isChecked = isOn;
  37.               this.checkedChange = !this.checkedChange;
  38.             })
  39.           }
  40.         }
  41.       }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
  42.     }
  43.     .columnsTemplate(('1fr 1fr 1fr 1fr') as string)
  44.     .height(Constants.FULL_HEIGHT)
  45.   }
  46. }
复制代码
在InTabsComponent组件中,绑定弹出框事件,点击时弹出选择分类组件。修改InTabsComponent.ets
  1. import SelectTabsComponent from "./SelectTabsComponent";
  2. @Builder
  3. sheetBuilder() {
  4.     SelectTabsComponent({ selectTabsViewModel: this.selectTabsViewModel })
  5. }
  6.   
  7. ...
  8. Row() {
  9.    Image($r('app.media.more'))
  10.    .onClick(() => {
  11.                 this.showSelectTabsComponent = !this.showSelectTabsComponent;
  12.    })
  13. }
  14. .bindSheet($$this.showSelectTabsComponent, this.sheetBuilder(), {
  15.             detents: [SheetSize.MEDIUM, SheetSize.MEDIUM, 500],
  16.             preferType: SheetType.BOTTOM,
  17.             title: { title: $r('app.string.bind_sheet_title') },
  18.             onWillDismiss: (dismissSheetAction: DismissSheetAction) => {
  19.               // update tab when closing modal box
  20.               this.selectTabsViewModel.updateSelectedTabs();
  21.               if (this.selectTabsViewModel.selectedTabs.length > 0) {
  22.                 this.subsController.changeIndex(0);
  23.               }
  24.               dismissSheetAction.dismiss();
  25.             }
  26. })
复制代码
点击图标,在弹出的页签中选择分类后关闭,内层Tabs的标签就自动显示选择的分类标签。
3.10 多语言测试

多语言的开发,开发者只需要准备不同语言的资源文件即可,匹配由系统自动实现。前面已经准备了中文和英文资源文件,即可实现多语言功能。
至于系统匹配的过程,只需要简单了解即可。系统匹配不同语言资源文件的过程和规则:程序运行时会获取系统语言与资源文件进行比对,如果系统语言是中文就匹配中文资源(zh_CN/element)。如果未匹配到,则获取用户首选项设置的语言进行比对,如果匹配到就显示对应的资源文件,否则就使用默认的资源配置文件(base/element/)。
这个匹配过程由系统自动完成,为了方面测试效果,可以使用18n手动设置语言首选项来改变语言环境。在entryability/EntryAbility.ets文件的onWindowStageCreate设置改变语言,观察效果。
  1. onWindowStageCreate(windowStage: window.WindowStage): void {
  2.     i18n.System.setAppPreferredLanguage("en");  //英文
  3.     // i18n.System.setAppPreferredLanguage("zh");  //中文
  4.     ...
  5. }
复制代码
程序运行后,改变首选项语言,可以看到中文和英文的界面。
至此,功能开发完成。
三、总结


  • 实现双层嵌套Tabs

    • 外层Tabs和内层Tabs均可滑动切换页签,内层滑到尽头触发外层滑动
    • 在内层Tabs最后一个TabContent上监听滑动手势,通过@Link传递变量到父组件的外层Tabs,然后通过外层Tabs的TabController控制其滑动

  • 实现Tabs滑动吸顶

    • Tabs父组件外及Tabs的TabContent组件内嵌套可滑动组件
    • 在TabContent内可滑动组件上设置滑动行为属性nestedScroll,使其往上滑动时,父组件先动,往下滑动时自己先动

  • 实现底部自定义变化页签

    • @Builder装饰器修饰的自定义builder函数,传递给TabBar,实现自定义样式
    • 设置currentIndex属性,记录当前选择的页签,并且@Builder修饰的TabBar构建函数中利用其值来区分当前页签是否被选中,以呈现不同的样式

  • 实现顶部可滑动标签

    • 设置Tabs组件属性barMode(BarMode.Scrollable),页签显示不下的时候就可滑动

  • 实现增删现实页签项

    • 利用@Link双向绑定selectTabsViewModel到InTabsComponent和SelectTabsComponent
    • SelectTabsComponent选中需要显示的页签项,在退出模态框时调用selectTabsViewModel.updateSelectedTabs,更新可显示页签
    • 更新后通过@Link的机制传递到InTabsComponent,触发UI刷新,显示新选择的页签

  • 实现Tabs切换动效

    • 在Tabs上注册动画方法customContentTransition(this.customContentTransition)
    • 在动画方法中修改TabContent的尺寸属性和透明属性,并通过@State修饰后传递给TabContent,来实现动画

《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!
关注后,评论区领取本案例项目代码!

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册