使用Tabs選項卡組件快速搭建鴻蒙APP框架

使用Tabs搭建鴻蒙APP框架

大家好,我是潘Sir,持續(xù)分享IT技術,幫你少走彎路?!而櫭蓱瞄_發(fā)從入門到項目實戰(zhàn)》系列文章持續(xù)更新中,陸續(xù)更新AI+編程、企業(yè)級項目實戰(zhàn)等原創(chuàng)內容、歡迎關注!

ArkUI提供了很多布局組件,其中Tabs選項卡組件可以用于快速搭建鴻蒙APP框架,本文通過案例研究Tabs構建鴻蒙原生應用框架的方法和步驟。

一、效果展示

1、效果展示

1.png

整個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ū)領取本案例項目代碼!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容