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

整个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+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!
关注后,评论区领取本案例项目代码!