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

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!

ArkUI提供了很多布局组件,其中Tabs选项卡组件可以用于快速搭建鸿蒙APP框架,本文通过案例研究Tabs构建鸿蒙原生应用框架的方法和步骤。

一、效果展示

1、效果展示

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

整个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文件,记录用到的常量。

export class Constants {   /**    * Full screen width.    */   static readonly FULL_WIDTH: string = '100%';   /**    * Full screen height.    */   static readonly FULL_HEIGHT: string = '100%'; } 

utils目录下新建StringUtil.ets文件,用于处理从文件中读取的数据。

import { util } from "@kit.ArkTS"; import { BusinessError } from "@kit.BasicServicesKit"; import { hilog } from "@kit.PerformanceAnalysisKit";  export default class StringUtil {   static async getStringFromRawFile(ctx: Context, source: string) {     try {       let getJson = await ctx.resourceManager.getRawFileContent(source);       let textDecoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });       let result = textDecoder.decodeToString(getJson);       return Promise.resolve(result);     } catch (error) {       let code = (error as BusinessError).code;       let message = (error as BusinessError).message;       hilog.error(0x0000, 'StringUtil', 'getStringSync failed,error code: %{code}s,message: %{message}s.', code,         message);       return Promise.reject(error);     }   } } 

2、整体框架

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

2.1 搜索组件

在ets目录下新建view目录用于存放组件,新建搜索组件SearchBarComponent.ets

import { Constants } from "../common/constant/Constants";  @Component export default struct SearchBarComponent {   @State changeValue: string = '';    build() {     Row() {       // 1、传统方法       // Stack() {       //   TextInput({ placeholder: $r('app.string.search_placeholder') })       //     .height(40)       //     .width(Constants.FULL_WIDTH)       //     .fontSize(16)       //     .placeholderColor(Color.Grey)       //     .placeholderFont({ size: 16, weight: FontWeight.Normal })       //     .borderStyle(BorderStyle.Solid)       //     .backgroundColor($r('app.color.search_bar_input_color'))       //     .padding({ left: 35, right: 66 })       //     .onChange((currentContent) => {       //       this.changeValue = currentContent;       //     })       //   Row() {       //     Image($r('app.media.ic_search')).width(20).height(20)       //     Button($r('app.string.search'))       //       .padding({ left: 20, right: 20 })       //       .height(36)       //       .fontColor($r('app.color.search_bar_button_color'))       //       .fontSize(16)       //       .backgroundColor($r('app.color.search_bar_input_color'))       //       //   }.width(Constants.FULL_WIDTH)       //   .hitTestBehavior(HitTestMode.None)       //   .justifyContent(FlexAlign.SpaceBetween)       //   .padding({ left: 10, right: 2 })       // }.alignContent(Alignment.Start)       // .width(Constants.FULL_WIDTH)        // 2、搜索组件       Search({placeholder:$r('app.string.search_placeholder')})         .searchButton('搜索')      }     .justifyContent(FlexAlign.SpaceBetween)     .padding(10)     .width(Constants.FULL_WIDTH)     .backgroundColor($r('app.color.out_tab_bar_background_color'))     .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])    } } 

在主界面引入,即可查看效果。修改Index.ets

import { Constants } from '../common/constant/Constants'; import SearchBarComponent from '../view/SearchBarComponent';  @Entry @Component struct Index {    build() {     Column() {       // 搜索栏       SearchBarComponent()     }     .height(Constants.FULL_HEIGHT)     .width(Constants.FULL_WIDTH)     .expandSafeArea([SafeAreaType.SYSTEM])   } } 

2.2 外层Tabs

通过界面分析,外层Tabs的每一个TabContent内容不同,可以抽取为组件。第一个TabContent抽取为组件InTabsComponent,后边的几个抽取为OtherTabContentComponent。

在view目录下新建组件:InTabsComponent.ets

@Component export default struct InTabsComponent {   build() {     Text('内层Tabs')   } } 

在InTabsComponent中,先简单写点提示信息,待整体框架完成后,后续再继续完成内层的内容。

在view目录下新建组件:OtherTabComponent.ets

import { Constants } from "../common/constant/Constants";  @Component export default struct OtherTabContentComponent {   @State bgColor: ResourceColor = $r('app.color.other_tab_content_default_color');    build() {     Column()       .width(Constants.FULL_WIDTH)       .height(Constants.FULL_HEIGHT)       .backgroundColor(this.bgColor)   } } 

在OtherTabComponent中,通过接收父组件传递的颜色参数来设置背景颜色,用以区分不同的Tab。

在view目录下,新建外层组件OutTabsComponent.ets

import { Constants } from "../common/constant/Constants"; import InTabsComponent from "./InTabsComponent"; import OtherTabContentComponent from "./OtherTabComponent";  @Component export default struct OutTabsComponent {   @State currentIndex: number = 0;   private tabsController: TabsController = new TabsController();    @Builder   tabBuilder(index: number, name: string | Resource, icon: Resource) {     Column() {       SymbolGlyph(icon).fontColor([this.currentIndex === index         ? $r('app.color.out_tab_bar_font_active_color')         : $r('app.color.out_tab_bar_font_inactive_color')])         .fontSize(25)        Text(name)         .margin({ top: 4 })         .fontSize(10)         .fontColor(this.currentIndex === index           ? $r('app.color.out_tab_bar_font_active_color')           : $r('app.color.out_tab_bar_font_inactive_color'))     }     .justifyContent(FlexAlign.Center)     .height(Constants.FULL_HEIGHT)     .width(Constants.FULL_WIDTH)     .padding({ bottom: 60 })   }   build() {     Tabs({       barPosition: BarPosition.End,       index: this.currentIndex,       controller: this.tabsController,     }) {       TabContent() {         InTabsComponent()       }.tabBar(this.tabBuilder(0, $r('app.string.out_bar_text_home'), $r('sys.symbol.house')))       TabContent() {         OtherTabContentComponent({ bgColor: Color.Blue })       }       .tabBar(this.tabBuilder(1, $r('app.string.out_bar_text_discover'), $r('sys.symbol.map_badge_local')))        TabContent() {         OtherTabContentComponent({ bgColor: Color.Yellow })       }       .tabBar(this.tabBuilder(2, $r('app.string.out_bar_text_messages'), $r('sys.symbol.ellipsis_message')))        TabContent() {         OtherTabContentComponent({ bgColor: Color.Orange })       }       .tabBar(this.tabBuilder(3, $r('app.string.out_bar_text_profile'), $r('sys.symbol.person')))     }     .vertical(false)     .barMode(BarMode.Fixed)     .scrollable(true) // false to disable scroll to switch     // .edgeEffect(EdgeEffect.None) // disables edge springback     .onChange((index: number) => {       this.currentIndex = index;     })     .height(Constants.FULL_HEIGHT)     .width(Constants.FULL_WIDTH)     .backgroundColor($r('app.color.out_tab_bar_background_color'))     .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])     .barHeight(120)     .barBackgroundBlurStyle(BlurStyle.COMPONENT_THICK)     .barOverlap(true)   } } 

在主界面中引入外层Tabs组件OutTabsComponent,修改主界面Index.ets

import OutTabsComponent from '../view/OutTabsComponent';  ...     // 外层tabs     OutTabsComponent() 

这样就实现了整体布局。

3、内层组件

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

3.1 Banner组件

接下来先封装顶部的Banner图片组件,在view目录下新建BannerComponent组件,BannerComponent.ets

import { Constants } from "../common/constant/Constants";  @Component export default struct BannerComponent {   build() {     Column() {       Image($r('app.media.pic5'))         .width(Constants.FULL_WIDTH)         .height(186)         .borderRadius(16)     }     .margin({       left: 5,       right: 5,       top: 10,       bottom: 2     })   } } 

3.2 列表项组件

接下来封装列表项组件ContentItemComponent,

封装数据类ContentItemModel,在ets目录下新建model目录,新建ContentItemModel.ets

export default class ContentItemModel {   username: string | Resource = '';   publishTime: string | Resource = '';   rawTitle: string | Resource = '';   title: string | Resource = '';   imgUrl1: string | Resource = '';   imgUrl2: string | Resource = '';   imgUrl3: string | Resource = '';   imgUrl4: string | Resource = ''; } 

封装数据类ContentItemViewModel,在ets目录下新建viewmodel目录,新建ContentItemViewModel.ets文件

import ContentItemModel from "../model/ContentItemModel";  @Observed export default class ContentItemViewModel {   username: string | Resource = '';   publishTime: string | Resource = '';   rawTitle: string | Resource = '';   title: string | Resource = '';   imgUrl1: string | Resource = '';   imgUrl2: string | Resource = '';   imgUrl3: string | Resource = '';   imgUrl4: string | Resource = '';    updateContentItem(contentItemModel: ContentItemModel) {     this.username = contentItemModel.username;     this.publishTime = contentItemModel.publishTime;     this.rawTitle = contentItemModel.rawTitle;     this.title = contentItemModel.title;     this.imgUrl1 = contentItemModel.imgUrl1;     this.imgUrl2 = contentItemModel.imgUrl2;     this.imgUrl3 = contentItemModel.imgUrl3;     this.imgUrl4 = contentItemModel.imgUrl4;   } } 

在view目录新建ContentItemComponent.ets

import { Constants } from "../common/constant/Constants"; import ContentItemViewModel from "../viewmodel/ContentItemViewModel";   @Component export default struct ContentItemComponent {   @Prop contentItemViewModel: ContentItemViewModel;    build() {     Column() {       Row() {         Image(this.contentItemViewModel.imgUrl1)           .width(30)           .height(30)           .borderRadius(15)         Column() {           Text(this.contentItemViewModel.username)             .fontSize(15)           Text(this.contentItemViewModel.publishTime)             .fontSize(12)             .fontColor($r('app.color.content_item_text_color'))         }         .margin({ left: 10 })         .justifyContent(FlexAlign.Start)         .alignItems(HorizontalAlign.Start)       }        Column() {         Text(this.contentItemViewModel.title)           .fontSize(16)           .id('title')           .textAlign(TextAlign.Start)        }       .margin({top:10, bottom: 10})        Row() {         Image(this.contentItemViewModel.imgUrl2)           .width(115)           .height(115)         Image(this.contentItemViewModel.imgUrl3)           .width(115)           .height(115)         Image(this.contentItemViewModel.imgUrl4)           .width(115)           .height(115)       }       .width(Constants.FULL_WIDTH)       .justifyContent(FlexAlign.SpaceBetween)     }     .width(Constants.FULL_WIDTH)     .alignItems(HorizontalAlign.Start)    } } 

3.3 列表数据封装

在制作列表项组件时封装了每一项数据对应的类ContentItemModel,还需要封装一个类用于表示整个Tabs界面的数据。

在model目录下新建InTabsModel.ets

import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import ContentItemModel from './ContentItemModel'; import StringUtil from '../common/utils/StringUtil';  export default class InTabsModel {   contentItems: ContentItemModel[] = [];    async loadContentItems(ctx: Context) {     let filename = '';     try {       filename = await ctx.resourceManager.getStringValue($r('app.string.default_content_items_file').id);     } catch (error) {       let err = error as BusinessError;       hilog.error(0x0000, 'InTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);     }      let res = await StringUtil.getStringFromRawFile(ctx, filename);      this.contentItems = JSON.parse(res).map((item: ContentItemModel) => {        let img1 = item.imgUrl1 as string;       if (img1.indexOf('app.media') === 0) {         item.imgUrl1 = $r(img1);       }        let img2 = item.imgUrl2 as string;       if (img2.indexOf('app.media') === 0) {         item.imgUrl2 = $r(img2);       }        let img3 = item.imgUrl3 as string;       if (img3.indexOf('app.media') === 0) {         item.imgUrl3 = $r(img3);       }        let img4 = item.imgUrl4 as string;       if (img4.indexOf('app.media') === 0) {         item.imgUrl4 = $r(img4);       }        return item;     });   } }  

该类主要实现从本地文件中读取列表数据。

在viewmodel目录下新建文件InTabsViewModel.ets

import ContentItemViewModel from "./ContentItemViewModel"; import InTabsModel from "../model/InTabsModel";  @Observed class ContentItemArray extends Array<ContentItemViewModel> { }  @Observed export default class InTabsViewModel {   private inTabsModel: InTabsModel = new InTabsModel();   contentItems: ContentItemArray = new ContentItemArray();    async loadContentData(ctx: Context) {     await this.inTabsModel.loadContentItems(ctx);      let tempItems: ContentItemArray = [];     for (let item of this.inTabsModel.contentItems) {       let contentItemViewModel = new ContentItemViewModel();       contentItemViewModel.updateContentItem(item);       tempItems.push(contentItemViewModel);     }     this.contentItems = tempItems;   } } 

3.4 Tab类封装

将每一个Tab抽象为TabItemModel类,以便于记录当前选中的选项卡。

在model目录下新建TabItemModel.ets

export default class TabItemModel {   id: number = 0;   name: string | Resource = '';   isChecked: boolean = true; } 

在viewmodel目录下新建TabItemViewModel.ets

import TabItemModel from "../model/TabItemModel";  @Observed export default class TabItemViewModel {   id: number = 0;   name: string | Resource = '';   isChecked: boolean = true;    updateTab(tabItemModel: TabItemModel) {     this.id = tabItemModel.id;     this.name = tabItemModel.name;     this.isChecked = tabItemModel.isChecked;   } } 

3.5 标签分类封装

内层Tabs的标签TarBar也是直接从文件读取,内层标签初始加载时直接读取文件内容进行显示,后续还需要添加分类的选择和取消功能,实现自定义显示分类。

本小节先封装相关类,在model目录下新建SelectTabsModel类,用于存取文件中的标签分类,SelectTabsModel.ets

import { BusinessError } from '@kit.BasicServicesKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import TabItemModel from './TabItemModel'; import StringUtil from '../common/utils/StringUtil';  export default class SelectTabsModel {   allTabs: TabItemModel[] = [];    async loadAllTabs(ctx: Context) {     let filename = '';     try {       filename = await ctx.resourceManager.getStringValue($r('app.string.default_all_tabs_file').id);     } catch (error) {       let err = error as BusinessError;       hilog.error(0x0000, 'SelectTabsModel', `getStringValue failed, error code=${err.code}, message=${err.message}`);     }     let result = await StringUtil.getStringFromRawFile(ctx, filename);     this.allTabs = JSON.parse(result);   } } 

在viewmodel目录下新建SelectTabsViewModel.ets

import TabItemViewModel from "./TabItemViewModel"; import SelectTabsModel from "../model/SelectTabsModel";  @Observed class TabItemArray extends Array<TabItemViewModel> { }  @Observed export default class SelectTabsViewModel {   allTabs: TabItemArray = new TabItemArray();   selectedTabs: TabItemArray = new TabItemArray();   private selectTabsModel: SelectTabsModel = new SelectTabsModel();    async loadTabs(ctx: Context) {     await this.selectTabsModel.loadAllTabs(ctx);      let tempTabs: TabItemViewModel[] = [];     for (let tab of this.selectTabsModel.allTabs) {       let tabItemViewModel = new TabItemViewModel();       tabItemViewModel.updateTab(tab);       tempTabs.push(tabItemViewModel);     }     this.allTabs = tempTabs;      this.updateSelectedTabs();   }    updateSelectedTabs() {     let tempTabs: TabItemViewModel[] = [];     for (let tab of this.allTabs) {       if (tab.isChecked) {         tempTabs.push(tab);       }     }     this.selectedTabs = tempTabs;   } } 

3.6 内层组件

修改InTabsComponent.ets

import { Constants } from "../common/constant/Constants"; import BannerComponent from "./BannerComponent"; import { CommonModifier } from "@kit.ArkUI"; import ContentItemComponent from "./ContentItemComponent"; import ContentItemViewModel from "../viewmodel/ContentItemViewModel"; import TabItemViewModel from "../viewmodel/TabItemViewModel"; import InTabsViewModel from "../viewmodel/InTabsViewModel"; import { EnvironmentCallback, Configuration, AbilityConstant } from "@kit.AbilityKit"; import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel";  @Component export default struct InTabsComponent {   @State selectTabsViewModel: SelectTabsViewModel = new SelectTabsViewModel();   @State inTabsViewModel: InTabsViewModel = new InTabsViewModel();   @State tabBarModifier: CommonModifier = new CommonModifier();   @State focusIndex: number = 0;    @State showSelectTabsComponent: boolean = false;   @State selectTabsComponentZIndex: number = -1;   private ctx: Context = this.getUIContext().getHostContext() as Context;   private subsController: TabsController = new TabsController();   private tabBarItemScroller: Scroller = new Scroller();    subscribeSystemLanguageUpdate() {     let systemLanguage: string | undefined;     let inTabsViewModel = this.inTabsViewModel;     let selectTabsViewModel = this.selectTabsViewModel;      let applicationContext = this.ctx.getApplicationContext();      let environmentCallback: EnvironmentCallback = {       async onConfigurationUpdated(newConfig: Configuration) {         if (systemLanguage !== newConfig.language) {           await inTabsViewModel.loadContentData(applicationContext);            await selectTabsViewModel.loadTabs(applicationContext);            systemLanguage = newConfig.language;         }       },       onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {         // do nothing       }     };     applicationContext.on('environment', environmentCallback);   }    async aboutToAppear() {     await this.inTabsViewModel.loadContentData(this.ctx);     await this.selectTabsViewModel.loadTabs(this.ctx);     this.tabBarModifier.margin({ right: 56 }).align(Alignment.Start);     this.subscribeSystemLanguageUpdate();   }    @Builder   tabBuilder(index: number, tab: TabItemViewModel) {     Row() {       Text(tab.name)         .fontSize(14)         .fontWeight(this.focusIndex === index ? FontWeight.Medium : FontWeight.Regular)         .fontColor(this.focusIndex === index ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))     }     .justifyContent(FlexAlign.Center)     .backgroundColor(this.focusIndex === index       ? $r('app.color.in_tab_bar_background_active_color')       : $r('app.color.in_tab_bar_background_inactive_color'))     .borderRadius(20)     .height(40)     .margin({ left: 4, right: 4 })     .padding({ left: 18, right: 18 })     .onClick(() => {       this.focusIndex = index;       this.subsController.changeIndex(index);       this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);     })   }    build() {     Scroll() {       Column() {         BannerComponent()          Stack({ alignContent: Alignment.TopEnd }) {           Row() {             Image($r('app.media.more'))               .width(20)               .height(20)               .margin({ left: 10 })               .onClick(() => {                 // todo:弹层选择分类               })           }           .margin({ top: 8, bottom: 8, right: 5 })           .backgroundColor($r('app.color.in_tab_bar_background_inactive_color'))           .width(40)           .height(40)           .borderRadius(20)           .zIndex(1)            Column() {             Tabs({               barPosition: BarPosition.Start,               controller: this.subsController,               barModifier: this.tabBarModifier             }) {               ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {                 TabContent() {                   List({ space: 10 }) {                     ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {                       ContentItemComponent({                         contentItemViewModel: item,                       })                     }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))                   }                   .padding({ left: 5, right: 5, bottom: 120 })                   .width(Constants.FULL_WIDTH)                   .height(Constants.FULL_HEIGHT)                   .scrollBar(BarState.Off)                 }                 .tabBar(this.tabBuilder(index, tab))               }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))             }             .barMode(BarMode.Scrollable)             .width(Constants.FULL_WIDTH)             .height(Constants.FULL_HEIGHT)             .barBackgroundColor($r('app.color.out_tab_bar_background_color'))             .scrollable(true)             .onChange((index: number) => {               this.focusIndex = index;               this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);               let preloadItems: number[] = [];               if (index - 1 >= 0) {                 preloadItems.push(index - 1);               }               if (index + 1 < this.selectTabsViewModel.selectedTabs.length) {                 preloadItems.push(index + 1);               }               this.subsController.preloadItems(preloadItems);             })           }           .width(Constants.FULL_WIDTH)           .height(Constants.FULL_HEIGHT)           .backgroundColor($r('app.color.out_tab_bar_background_color'))         }        }     }     .scrollBar(BarState.Off)     .width(Constants.FULL_WIDTH)     .height(Constants.FULL_HEIGHT)     .backgroundColor($r('app.color.out_tab_bar_background_color'))     .padding({ left: 5, right: 5 })   } }  

这样基本效果就实现了。

3.7 吸顶效果

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

修改InTabsComponent,为List组件添加nestedScroll属性

... List(){     ... } .nestedScroll({     scrollForward: NestedScrollMode.PARENT_FIRST,     scrollBackward: NestedScrollMode.SELF_FIRST  }) ... 

3.8 内外联动

当滑动内层Tabs最后一个时,需要联动外层滚动。

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

在InTabsComponent组件中,通过ForEach遍历生成TabContent时,需要给最后一项绑定 滚动手势,设置当前是最后一项的标识。InTabsComponent.ets

@Link switchNext: boolean; //是否内层Tab最后一项 ... Tabs(){    ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {      if (index === this.selectTabsViewModel.selectedTabs.length - 1) {           TabContent() {                 List({ space: 10 }) {                     ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {                         ContentItemComponent({                           contentItemViewModel: item,                         })                       }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))                     }                     .padding({ left: 5, right: 5, bottom: 120 })                     .width(Constants.FULL_WIDTH)                     .height(Constants.FULL_HEIGHT)                     .scrollBar(BarState.Off)                     .nestedScroll({                       scrollForward: NestedScrollMode.PARENT_FIRST,                       scrollBackward: NestedScrollMode.SELF_FIRST                     })                   }                   .tabBar(this.tabBuilder(index, tab))                   .gesture(PanGesture(new PanGestureOptions({ direction: PanDirection.Left })).onActionStart(() => {                     this.switchNext = true;                   }))         }else {                   TabContent() {                     List({ space: 10 }) {                       ForEach(this.inTabsViewModel.contentItems, (item: ContentItemViewModel, index: number) => {                         ContentItemComponent({                           contentItemViewModel: item,                         })                       }, (item: ContentItemViewModel, index: number) => index + '_' + JSON.stringify(item))                     }                     .padding({ left: 5, right: 5, bottom: 120 })                     .width(Constants.FULL_WIDTH)                     .height(Constants.FULL_HEIGHT)                     .scrollBar(BarState.Off)                     .nestedScroll({                       scrollForward: NestedScrollMode.PARENT_FIRST,                       scrollBackward: NestedScrollMode.SELF_FIRST                     })                   }                   .tabBar(this.tabBuilder(index, tab))                 }                }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))             }  } 

外层组件OutTabsComponent传递参数,并监听该参数,一旦子组件回传的参数改变,则调用外层Tabs的控制器来改变外层Tab选择项,选中下一页。

 @State @Watch('onchangeSwitchNext') switchNext: boolean = false;    onchangeSwitchNext() {     if (this.switchNext) {       this.switchNext = false;       this.tabsController.changeIndex(1);     }   }  TabContent() {      InTabsComponent({ switchNext: this.switchNext }) } 

这样就实现了内层组件与外层组件联动。

3.9 分类选择

在首页中,分类可以由用户自定义选择,点击图片弹出组件InTabsModel。

制作选择分类组件SelectTabsComponent,在view目录下新建SelectTabsComponent.ets

import { Constants } from "../common/constant/Constants"; import SelectTabsViewModel from "../viewmodel/SelectTabsViewModel" import TabItemViewModel from "../viewmodel/TabItemViewModel";  @Component export default struct SelectTabsComponent {   @State checkedChange: boolean = false;   @Link selectTabsViewModel: SelectTabsViewModel;   build() {     Grid() {       ForEach(this.selectTabsViewModel.allTabs, (tab: TabItemViewModel) => {         GridItem() {           Row() {             Toggle({ type: ToggleType.Button, isOn: tab.isChecked }) {               if (this.checkedChange) {                 Text(tab.name)                   .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))                   .fontSize(14)               } else {                 Text(tab.name)                   .fontColor(tab.isChecked ? Color.White : $r('app.color.in_tab_bar_text_normal_color'))                   .fontSize(14)               }             }             .width($r('app.integer.in_tab_bar_width'))             .borderRadius(20)             .height(40)             .margin({               left: 4,               right: 4,               top: 10,               bottom: 10             })             .padding({ left: 12, right: 12 })             .selectedColor($r('app.color.in_tab_bar_background_active_color'))             .onChange((isOn: boolean) => {               tab.isChecked = isOn;               this.checkedChange = !this.checkedChange;             })           }         }       }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))     }     .columnsTemplate(('1fr 1fr 1fr 1fr') as string)     .height(Constants.FULL_HEIGHT)   } } 

在InTabsComponent组件中,绑定弹出框事件,点击时弹出选择分类组件。修改InTabsComponent.ets

import SelectTabsComponent from "./SelectTabsComponent";  @Builder sheetBuilder() {     SelectTabsComponent({ selectTabsViewModel: this.selectTabsViewModel }) }    ... Row() {    Image($r('app.media.more'))    .onClick(() => {                 this.showSelectTabsComponent = !this.showSelectTabsComponent;    }) } .bindSheet($$this.showSelectTabsComponent, this.sheetBuilder(), {             detents: [SheetSize.MEDIUM, SheetSize.MEDIUM, 500],             preferType: SheetType.BOTTOM,             title: { title: $r('app.string.bind_sheet_title') },             onWillDismiss: (dismissSheetAction: DismissSheetAction) => {               // update tab when closing modal box               this.selectTabsViewModel.updateSelectedTabs();               if (this.selectTabsViewModel.selectedTabs.length > 0) {                 this.subsController.changeIndex(0);               }               dismissSheetAction.dismiss();             }  }) 

点击图标,在弹出的页签中选择分类后关闭,内层Tabs的标签就自动显示选择的分类标签。

3.10 多语言测试

多语言的开发,开发者只需要准备不同语言的资源文件即可,匹配由系统自动实现。前面已经准备了中文和英文资源文件,即可实现多语言功能。

至于系统匹配的过程,只需要简单了解即可。系统匹配不同语言资源文件的过程和规则:程序运行时会获取系统语言与资源文件进行比对,如果系统语言是中文就匹配中文资源(zh_CN/element)。如果未匹配到,则获取用户首选项设置的语言进行比对,如果匹配到就显示对应的资源文件,否则就使用默认的资源配置文件(base/element/)。

这个匹配过程由系统自动完成,为了方面测试效果,可以使用18n手动设置语言首选项来改变语言环境。在entryability/EntryAbility.ets文件的onWindowStageCreate设置改变语言,观察效果。

onWindowStageCreate(windowStage: window.WindowStage): void {     i18n.System.setAppPreferredLanguage("en");  //英文     // i18n.System.setAppPreferredLanguage("zh");  //中文     ...  } 

程序运行后,改变首选项语言,可以看到中文和英文的界面。

至此,功能开发完成。

三、总结

  • 实现双层嵌套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+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!

关注后,评论区领取本案例项目代码!

发表评论

评论已关闭。

相关文章