使用Tabs搭建鴻蒙APP框架
大家好,我是潘Sir,持續(xù)分享IT技術,幫你少走彎路?!而櫭蓱瞄_發(fā)從入門到項目實戰(zhàn)》系列文章持續(xù)更新中,陸續(xù)更新AI+編程、企業(yè)級項目實戰(zhàn)等原創(chuàng)內容、歡迎關注!
ArkUI提供了很多布局組件,其中Tabs選項卡組件可以用于快速搭建鴻蒙APP框架,本文通過案例研究Tabs構建鴻蒙原生應用框架的方法和步驟。
一、效果展示
1、效果展示

整個APP外層Tabs包含4個選項卡:首頁、發(fā)現(xiàn)、消息、我的。在首頁中,上滑列表會出現(xiàn)吸頂效果,分類可以左右滑動,當滑到最后一個分類時,與外層Tabs聯(lián)動,滑到“發(fā)現(xiàn)”頁面。首頁中的分類標簽可以用戶自定義選擇顯示。
2、技術分析
主要使用Tabs選項卡搭建整個APP的框架,通過設置Tabs相關的屬性和方法實現(xiàn)布局、滾動、吸頂、內外層嵌套聯(lián)動等功能。
Tabs組件的頁面組成包含兩個部分,分別是TabContent和TabBar。TabContent是內容頁,TabBar是導航頁簽欄,,根據(jù)不同的導航類型,布局會有區(qū)別,可以分為底部導航、頂部導航、側邊導航,其導航欄分別位于底部、頂部和側邊。
本例中通過嵌套Tabs實現(xiàn),外層Tabs為底部導航、內層Tabs為頂部導航。
二、功能實現(xiàn)
1、準備工作
1.1 數(shù)據(jù)準備
在商業(yè)項目中,界面顯示的數(shù)據(jù)是通過網(wǎng)絡請求后端接口獲得,本例重點放在Tabs組件的用法研究上,因此簡化數(shù)據(jù)獲取過程,直接將數(shù)據(jù)寫入到json文件中。
將準備好的界面數(shù)據(jù)文件(tab標簽和數(shù)據(jù)列表)拷貝到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文件,用于處理從文件中讀取的數(shù)據(jù)。
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、傳統(tǒng)方法
// 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中,先簡單寫點提示信息,待整體框架完成后,后續(xù)再繼續(xù)完成內層的內容。
在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中,通過接收父組件傳遞的顏色參數(shù)來設置背景顏色,用以區(qū)分不同的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()
這樣就實現(xiàn)了整體布局。
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,
封裝數(shù)據(jù)類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 = '';
}
封裝數(shù)據(jù)類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 列表數(shù)據(jù)封裝
在制作列表項組件時封裝了每一項數(shù)據(jù)對應的類ContentItemModel,還需要封裝一個類用于表示整個Tabs界面的數(shù)據(jù)。
在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;
});
}
}
該類主要實現(xiàn)從本地文件中讀取列表數(shù)據(jù)。
在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也是直接從文件讀取,內層標簽初始加載時直接讀取文件內容進行顯示,后續(xù)還需要添加分類的選擇和取消功能,實現(xiàn)自定義顯示分類。
本小節(jié)先封裝相關類,在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 })
}
}
這樣基本效果就實現(xiàn)了。
3.7 吸頂效果
Tabs父組件外及Tabs的TabContent組件內嵌套可滑動組件。在TabContent內可滑動組件上設置滑動行為屬性nestedScroll,使其往上滑動時,父組件先動,往下滑動時自己先動。
修改InTabsComponent,為List組件添加nestedScroll屬性
...
List(){
...
}
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
...
3.8 內外聯(lián)動
當滑動內層Tabs最后一個時,需要聯(lián)動外層滾動。
實現(xiàn)思路:外層Tabs和內層Tabs均可滑動切換頁簽,內層滑到盡頭觸發(fā)外層滑動;在內層Tabs最后一個TabContent上監(jiān)聽滑動手勢,通過@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傳遞參數(shù),并監(jiān)聽該參數(shù),一旦子組件回傳的參數(shù)改變,則調用外層Tabs的控制器來改變外層Tab選擇項,選中下一頁。
@State @Watch('onchangeSwitchNext') switchNext: boolean = false;
onchangeSwitchNext() {
if (this.switchNext) {
this.switchNext = false;
this.tabsController.changeIndex(1);
}
}
TabContent() {
InTabsComponent({ switchNext: this.switchNext })
}
這樣就實現(xiàn)了內層組件與外層組件聯(lián)動。
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 多語言測試
多語言的開發(fā),開發(fā)者只需要準備不同語言的資源文件即可,匹配由系統(tǒng)自動實現(xiàn)。前面已經(jīng)準備了中文和英文資源文件,即可實現(xiàn)多語言功能。
至于系統(tǒng)匹配的過程,只需要簡單了解即可。系統(tǒng)匹配不同語言資源文件的過程和規(guī)則:程序運行時會獲取系統(tǒng)語言與資源文件進行比對,如果系統(tǒng)語言是中文就匹配中文資源(zh_CN/element)。如果未匹配到,則獲取用戶首選項設置的語言進行比對,如果匹配到就顯示對應的資源文件,否則就使用默認的資源配置文件(base/element/)。
這個匹配過程由系統(tǒng)自動完成,為了方面測試效果,可以使用18n手動設置語言首選項來改變語言環(huán)境。在entryability/EntryAbility.ets文件的onWindowStageCreate設置改變語言,觀察效果。
onWindowStageCreate(windowStage: window.WindowStage): void {
i18n.System.setAppPreferredLanguage("en"); //英文
// i18n.System.setAppPreferredLanguage("zh"); //中文
...
}
程序運行后,改變首選項語言,可以看到中文和英文的界面。
至此,功能開發(fā)完成。
三、總結
-
實現(xiàn)雙層嵌套Tabs
外層Tabs和內層Tabs均可滑動切換頁簽,內層滑到盡頭觸發(fā)外層滑動
在內層Tabs最后一個TabContent上監(jiān)聽滑動手勢,通過@Link傳遞變量到父組件的外層Tabs,然后通過外層Tabs的TabController控制其滑動
-
實現(xiàn)Tabs滑動吸頂
Tabs父組件外及Tabs的TabContent組件內嵌套可滑動組件
在TabContent內可滑動組件上設置滑動行為屬性nestedScroll,使其往上滑動時,父組件先動,往下滑動時自己先動
-
實現(xiàn)底部自定義變化頁簽
@Builder裝飾器修飾的自定義builder函數(shù),傳遞給TabBar,實現(xiàn)自定義樣式
設置currentIndex屬性,記錄當前選擇的頁簽,并且@Builder修飾的TabBar構建函數(shù)中利用其值來區(qū)分當前頁簽是否被選中,以呈現(xiàn)不同的樣式
-
實現(xiàn)頂部可滑動標簽
- 設置Tabs組件屬性barMode(BarMode.Scrollable),頁簽顯示不下的時候就可滑動
-
實現(xiàn)增刪現(xiàn)實頁簽項
利用@Link雙向綁定selectTabsViewModel到InTabsComponent和SelectTabsComponent
SelectTabsComponent選中需要顯示的頁簽項,在退出模態(tài)框時調用selectTabsViewModel.updateSelectedTabs,更新可顯示頁簽
更新后通過@Link的機制傳遞到InTabsComponent,觸發(fā)UI刷新,顯示新選擇的頁簽
-
實現(xiàn)Tabs切換動效
在Tabs上注冊動畫方法customContentTransition(this.customContentTransition)
在動畫方法中修改TabContent的尺寸屬性和透明屬性,并通過@State修飾后傳遞給TabContent,來實現(xiàn)動畫
《鴻蒙應用開發(fā)從入門到項目實戰(zhàn)》系列文章持續(xù)更新中,陸續(xù)更新AI+編程、企業(yè)級項目實戰(zhàn)等原創(chuàng)內容,防止迷路,歡迎關注!
關注后,評論區(qū)領取本案例項目代碼!