大家好,我是潘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+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!

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

使用Tabs选项卡组件快速搭建鸿蒙APP框架的更多相关文章

  1. 第二百节,jQuery EasyUI,Tabs(选项卡)组件

    jQuery EasyUI,Tabs(选项卡)组件 学习要点: 1.加载方式 2.属性列表 3.事件列表 4.方法列表 5.选项卡面板 本节课重点了解 EasyUI 中 Tabs(选项卡)组件的使用方 ...

  2. Electron入门笔记(一)-自己快速搭建一个app demo

    Electron学习-快速搭建app demo 作者: 狐狸家的鱼 Github: 八至 一.安装Node 1.从node官网下载 ,最好安装.msi后缀名的文件,新手可以查看安装教程进行安装. 2. ...

  3. 快速搭建一个SSM框架demo

    我之所以写一个快速搭建的demo,主要想做一些容器的demo,所以为了方便大家,所以一切从简,简单的3层架构 先用mysql的ddl,后期不上oracle的ddl ; -- ------------- ...

  4. 使用cordova + vue搭建混合app框架

    版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明.本文链接:https://blog.csdn.net/zxj0904010228/article ...

  5. 前端组件库 - 搭建web app常用的样式/组件等收集列表(移动优先)

    0. 前端自动化(Workflow) 前端构建工具 Webpack - module bundler Yeoman - a set of tools for automating developmen ...

  6. Cordova+Vue快速搭建Hybrid App

    前言 最近项目迭代需要开发一个app,由于项目组其他系统前端技术栈都是Vue,所以自己在需求评估的时候就初步敲定了Cordova+Vue的前端架构,后来查阅了不少资料,也掉了不少坑,这里总结一下,也算 ...

  7. rest_framework之ModelViewSet、路由控制、序列化组件快速搭建项目雏形

    以UserInfo表登陆接口为例 ModelViewSet的用法十分简单,定义一个视图类,指定一个模型表,指定一个序列化类即可帮我们完成增删改查等功能 示例: # 视图层 from app01.MyS ...

  8. 使用vue2.x+webpack+vuex+sass+axios+elementUI等快速搭建前端项目框架

    一.本文将分享如何快速搭起基于webpack+vue的前端项目框架,利用vue的自己的脚手架工具vue-cli搭建起基本的环境配置,再通过npm包管理工具引入相应的依赖来完善项目的各种依赖框架.下面是 ...

  9. Spring-boot:快速搭建微服务框架

    前言: Spring Boot是为了简化Spring应用的创建.运行.调试.部署等而出现的,使用它可以做到专注于Spring应用的开发,而无需过多关注XML的配置. 简单来说,它提供了一堆依赖打包,并 ...

  10. springboot入门(一)--快速搭建一个springboot框架

    原文出处 前言在开始之前先简单介绍一下springboot,springboot作为一个微框架,它本身并不提供Spring框架的核心特性以及扩展功能,只是用于快速.敏捷地开发新一代基于Spring框架 ...

随机推荐

  1. 故障处理:Oracle一体机更换磁盘控制器后部分磁盘状态异常的案例处理

    我们的文章会在微信公众号IT民工的龙马人生和博客网站( www.htz.pw )同步更新 ,欢迎关注收藏,也欢迎大家转载,但是请在文章开始地方标注文章出处,谢谢! 由于博客中有大量代码,通过页面浏览效 ...

  2. vuepress右侧小目录 二级目录 右侧锚点 模拟Docusaurus效果

    vuepress-plugin-anchor-right 简体中文 | English vuepress-plugin-anchor-right一款用于vuepress2.x的插件. 用于生成右侧导航 ...

  3. java 两个对象copy,并移除或添加一些属性

    前言 目前遇到了 后端查到的数据和我想给前端返回的数据 不太一致的困惑. 因为不想因为返回给前端少一个字段,我这边就用不成了select *. 所以听了@朱杰 大佬的建议,这样搞. 其实这样我也不满意 ...

  4. Skill Discovery | DoDont:使用 do + don't 示例视频,引导 agent 学习人类期望的 skill

    论文标题:Do's and Don'ts: Learning Desirable Skills with Instruction Videos NeurIPS 2024 poster. arxiv:h ...

  5. bezier 曲线

    简介 bezier 曲线, 简单而言就是多项式曲线. 参考链接 https://www.zhihu.com/question/29565629 The Nurbs Book https://gitee ...

  6. leetcode 918

    简介 环形数组的最大子数组的和的最大值. 思路 分两种情况讨论, 一种是最大子数组就是普通值, 那么只要求出正常值就可以了. 另一种情况是除去全局最小的中间一段, 然后就是最大值. code clas ...

  7. unity 组合键 搓招 流星蝴蝶剑

    转载:https://www.zhihu.com/question/36951135/answer/69880133 bilibili  的up实现:https://www.bilibili.com/ ...

  8. OAuth2.0系列之信息Redis存储实践(七)

    @ 目录 1.文章前言介绍 2.典型例子实践 3.功能简单测试 OAuth2.0系列博客: OAuth2.0系列之基本概念和运作流程(一) OAuth2.0系列之授权码模式实践教程(二) OAuth2 ...

  9. A. Heating -Codeforces Round 77 (Div. 2)

    http://codeforces.com/contest/1260/problem/A A. Heating time limit per test 1 second memory limit pe ...

  10. 基于 .NET 开源、功能齐全的分布式作业调度系统

    前言 在当今企业级应用开发中,可靠的任务调度系统已成为支撑业务连续性的关键基础设施.今天大姚给大家分享一个基于 .NET 开源.功能齐全的分布式作业调度系统:Sundial. 系统介绍 Sundial ...