HarmonyOS鸿蒙NEXT中如何深度定制一多组件?以这里tabs为例

HarmonyOS鸿蒙NEXT中如何深度定制一多组件?以这里tabs为例 参考文档,写了一个简单的tabs的一多demo

请问如何深度自定义这些组件?比如实现WPS那样的跨设备的一多导航栏?

样式可以稍微复杂一些,除了实现官方这里简单基础的tabs功能,最好有一些必须要自定义才能实现的,可以简单给一个可以运行的demo

cke_486134.png

听说WPS是用的QT?如果这个方便的话,能否也给一个简单的demo说明一下如何实现?非常感谢

官方文档中的简单一多工程,以及断点相关的科普可以省略

cke_185.png

import { BreakpointSystem, BreakpointTypeEnum } from 'utils';

@Entry
@Component
struct TabsExample {
  private breakpointSystem: BreakpointSystem = new BreakpointSystem();

  // 每个 Tabs 独立 controller
  private mainTabsController: TabsController = new TabsController();
  private subTabsController1: TabsController = new TabsController();
  private subTabsController2: TabsController = new TabsController();

  @StorageProp('currentBreakpoint') currentBreakpoint: string = BreakpointTypeEnum.MD;

  @State barMode: BarMode = BarMode.Fixed;

  aboutToAppear() {
    AppStorage.setOrCreate('UIContext', this.getUIContext());
    this.breakpointSystem.register();
  }

  aboutToDisappear() {
    this.breakpointSystem.unregister();
  }

  private isLarge(): boolean {
    return this.currentBreakpoint === BreakpointTypeEnum.LG;
  }

  build() {
    Column() {
      Row() {

        // ===== 外层 Tabs =====
        Tabs({
          barPosition: this.isLarge() ? BarPosition.End : BarPosition.Start,
          controller: this.mainTabsController
        }) {

          // ===== 首页1 =====
          TabContent() {
            Tabs({
              barPosition: this.isLarge() ? BarPosition.End : BarPosition.Start,
              controller: this.subTabsController1
            }) {

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Blue)
              }.tabBar(SubTabBarStyle.of('子页a'))

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Green)
              }.tabBar(SubTabBarStyle.of('子页b'))

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Pink)
              }.tabBar(SubTabBarStyle.of('子页c'))

            }
            .nestedScroll(TabsNestedScrollMode.SELF_FIRST)
            .vertical(this.isLarge()) 
            .barWidth(this.isLarge() ? 120 : undefined)
          }
          .tabBar(SubTabBarStyle.of("首页1"))



          // ===== 首页2 =====
          TabContent() {
            Tabs({
              barPosition: this.isLarge() ? BarPosition.End : BarPosition.Start,
              controller: this.subTabsController2
            }) {

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Blue)
              }.tabBar(SubTabBarStyle.of('子页d'))

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Green)
              }.tabBar(SubTabBarStyle.of('子页e'))

              TabContent() {
                Column()
                  .width('100%')
                  .height('100%')
                  .backgroundColor(Color.Pink)
              }.tabBar(SubTabBarStyle.of('子页f'))

            }
            .nestedScroll(TabsNestedScrollMode.SELF_FIRST)
            .vertical(this.isLarge())
            .barWidth(this.isLarge() ? 120 : undefined)
          }
          .tabBar(SubTabBarStyle.of('首页2'))

        }
        .height('100%')
        .backgroundColor(0xf1f3f5)
        .barMode(this.barMode)
        .vertical(this.isLarge()) 
        .barWidth(this.isLarge() ? 120 : undefined)
      }
      .width('100%')
      .height('100%')
      .padding('24vp')
    }
  }
}
// BreakpointSystem.ets
import { mediaquery } from '@kit.ArkUI';

declare interface BreakpointTypeOption<T> {
  xs?: T
  sm?: T
  md?: T
  lg?: T
  xl?: T
  xxl?: T
}

export class BreakpointType<T> {
  options: BreakpointTypeOption<T>;

  constructor(option: BreakpointTypeOption<T>) {
    this.options = option
  }

  getValue(currentBreakPoint: string) {
    if (currentBreakPoint === 'xs') {
      return this.options.xs;
    } else if (currentBreakPoint === 'sm') {
      return this.options.sm;
    } else if (currentBreakPoint === 'md') {
      return this.options.md;
    } else if (currentBreakPoint === 'lg') {
      return this.options.lg;
    } else if (currentBreakPoint === 'xl') {
      return this.options.xl;
    } else if (currentBreakPoint === 'xxl') {
      return this.options.xxl;
    } else {
      return undefined;
    }
  }
}

interface Breakpoint {
  name: string,
  size: number,
  mediaQueryListener?: mediaquery.MediaQueryListener
}

export enum BreakpointTypeEnum {
  SM = 'sm',
  MD = 'md',
  LG = 'lg',
  XL = 'xl'
}

export class BreakpointSystem {
  private currentBreakpoint: string = "md";
  private breakpoints: Breakpoint[] = [
    { name: 'sm', size: 320 },
    { name: 'md', size: 600 },
    { name: 'lg', size: 840 },
    { name: 'xl', size: 1500 }
  ];

  private updateCurrentBreakpoint(breakpoint: string) {
    if (this.currentBreakpoint !== breakpoint) {
      this.currentBreakpoint = breakpoint;
      AppStorage.setOrCreate<string>('currentBreakpoint', this.currentBreakpoint);
      console.log('on current breakpoint: ' + this.currentBreakpoint);
    }
  }

  public register() {
    this.breakpoints.forEach((breakpoint: Breakpoint, index) => {
      let condition: string;
      if (index === this.breakpoints.length - 1) {
        condition = '(' + breakpoint.size + 'vp<=width' + ')';
      } else {
        condition = '(' + breakpoint.size + 'vp<=width<' + this.breakpoints[index + 1].size + 'vp)';
      }
      let UIContext :UIContext= AppStorage.get('UIContext') as UIContext;
      breakpoint.mediaQueryListener = UIContext.getMediaQuery().matchMediaSync(condition);
      breakpoint.mediaQueryListener.on('change', (mediaQueryResult) => {
        if (mediaQueryResult.matches) {
          this.updateCurrentBreakpoint(breakpoint.name);
        }
      })
    })
  }

  public unregister() {
    this.breakpoints.forEach((breakpoint: Breakpoint) => {
      if (breakpoint.mediaQueryListener) {
        breakpoint.mediaQueryListener.off('change');
      }
    })
  }
}

更多关于HarmonyOS鸿蒙NEXT中如何深度定制一多组件?以这里tabs为例的实战教程也可以访问 https://www.itying.com/category-93-b0.html

5 回复

如果目标是 WPS 这类办公应用的“一多导航”,不建议继续把所有结构都塞进 Tabs。Tabs 适合做同层内容切换,但复杂一多更建议拆成三层:

  1. 断点层:统一维护 sm/md/lg/xl。
  2. 导航层:手机用底部/顶部 Tabs,平板和 2in1 用 SideBarContainer 或左侧自定义 rail。
  3. 内容层:用 Navigation + NavPathStack 管详情页,宽屏时切 NavigationMode.Split,窄屏时切 Stack。

核心代码可以这样组织:

@Builder
NavItem(icon: Resource, text: string, index: number) {
  Column() {
    Image(icon).width(24).height(24)
    Text(text).fontSize(12)
  }
  .width(72)
  .height(56)
  .backgroundColor(this.current === index ? '#e8f0ff' : Color.Transparent)
  .onClick(() => this.current = index)
}

宽屏时把这些 NavItem 放到左侧 Column,窄屏时放到底部 Row;右侧内容区域根据 current 渲染不同业务页。真正的“深度定制”通常不是改 Tabs 内置 tabBar,而是自建导航条,让状态、路由和断点都在你自己手里。Qt 方向也能做,但在 HarmonyOS NEXT 原生应用里,优先用 ArkUI 的 Navigation/SideBarContainer/Grid 组合,后续维护和系统适配会更稳。

更多关于HarmonyOS鸿蒙NEXT中如何深度定制一多组件?以这里tabs为例的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


尊敬的开发者,您好,tabs一多适配开发者可参考底部/侧边导航基于一多能力实现响应式布局。深度自定义tabs组件,您具体可以参考如下demo,如果还是不能满足您的需求,您可以提供更详细的需求信息。关于您对WPS应用的一些疑问,您可以询问WPS的官网进行询问。
示例代码如下:

// Cover.ets
import { MediaQuery, mediaquery } from '@kit.ArkUI';

export class MediaListener {
  private static instance: MediaListener | undefined = undefined;

  public static getInstance(): MediaListener {
    if (!MediaListener.instance) {
      MediaListener.instance = new MediaListener();
    }
    return MediaListener.instance;
  }

  // 定义屏幕宽度断点(单位:vp)
  private SM_SCREEN: string = '(max-width: 599vp)'; // 小屏设备
  private MD_SCREEN: string = '(min-width: 600vp) and (max-width: 839vp)'; // 中屏
  private LG_SCREEN: string = '(min-width: 840vp) and (max-width: 1049)'; // 大屏
  private XL_SCREEN: string = '(min-width: 1051vp)'; // 超大屏
  smListener: mediaquery.MediaQueryListener | undefined = undefined;
  mdListener: mediaquery.MediaQueryListener | undefined = undefined;
  lgListener: mediaquery.MediaQueryListener | undefined = undefined;
  xlListener: mediaquery.MediaQueryListener | undefined = undefined;

  initListen(uiContext: UIContext) {
    let mediaQuery: MediaQuery = uiContext.getMediaQuery();
    this.smListener = mediaQuery.matchMediaSync(this.SM_SCREEN);
    this.mdListener = mediaQuery.matchMediaSync(this.MD_SCREEN);
    this.lgListener = mediaQuery.matchMediaSync(this.LG_SCREEN);
    this.xlListener = mediaQuery.matchMediaSync(this.XL_SCREEN);
  }

  startListen() {
    // sm小屏监听回调
    this.smListener?.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        AppStorage.setOrCreate('windowSize', 1);
      }
    });

    // md中屏监听回调
    this.mdListener?.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        AppStorage.setOrCreate('windowSize', 2);
      }
    });

    // lg大屏监听回调
    this.lgListener?.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        AppStorage.setOrCreate('windowSize', 3);
      }
    });

    // xl超大屏监听回调
    this.xlListener?.on('change', (result: mediaquery.MediaQueryResult) => {
      if (result.matches) {
        AppStorage.setOrCreate('windowSize', 4);
      }
    });
  }

  stopListen() {
    this.smListener?.off('change');
    this.mdListener?.off('change');
    this.lgListener?.off('change');
    this.xlListener?.off('change');
  }
}
import { MediaListener } from './Cover';
import { CommonModifier, window } from '@kit.ArkUI';

interface TabItem {
  icon: Resource;
  name: string;
  index: number;
}

@Entry
@Component
struct Index {
  windowStage: window.WindowStage = AppStorage.get('windowStage') as window.WindowStage;
  tabController: TabsController = new TabsController();
  tabList: TabItem[] = [
    { icon: $r('app.media.startIcon'), name: '页签1', index: 0 }, // 图片资源替换成所需资源
    { icon: $r('app.media.startIcon'), name: '页签2', index: 1 }// 图片资源替换成所需资源
  ];
  childList: string[] = ['精选', '视频', '社区', '新闻'];
  @State tabPosition: BarPosition = BarPosition.End;
  @State isVertical: boolean = false;
  @State imgWidth: number = 40;
  @State nameSize: number = 16;
  @State childNameSize: number = 20;
  // 实现Tabs一多的适配,首先需要使用@StorageProp装饰器获取到全局的断点值。然后就可以使用断点值来进行一多适配了。
  @StorageProp('windowSize') @Watch('windowSizeChange') windowSize: number = 0;
  @State currentIndex: number = 0;
  @State childIndex: number = 0;
  @State childIndex2: number = 0;

  @Builder
  tabBuilder(item: TabItem) {
    Flex({
      direction: this.windowSize <= 2 ? FlexDirection.Column : FlexDirection.Row,
      justifyContent: FlexAlign.Center,
      alignItems: ItemAlign.Center
    }) {
      Image(item.icon)
        .width(this.imgWidth)
        .aspectRatio(1);
      Text(item.name)
        .fontSize(this.nameSize)
        .fontWeight(FontWeight.Medium)
        .fontColor(this.currentIndex === item.index ? '#007df5' : '#000000');
    }
    .padding(16)
    .width('100%')
    .height('100%');
  }

  @Builder
  childTabBar(item: string, index: number) {
    Flex({ justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
      Text(item)
        .fontSize(this.nameSize)
        .fontWeight(this.childIndex === index ? FontWeight.Bold : FontWeight.Regular);
    }
    .width(80)
    .height('25%');
  }

  windowSizeChange() {
    // 根据窗口大小变化,控制Tabs页签的横竖显示等属性
    if (this.windowSize === 1) {
      this.isVertical = false;
      this.tabPosition = BarPosition.Start;
      this.imgWidth = 40;
      this.nameSize = 16;
      this.childNameSize = 22;
    } else if (this.windowSize === 2) {
      this.isVertical = false;
      this.tabPosition = BarPosition.Start;
      this.imgWidth = 50;
      this.nameSize = 22;
      this.childNameSize = 26;
    } else {
      this.isVertical = true;
      this.tabPosition = BarPosition.End;
      this.imgWidth = 60;
      this.nameSize = 28;
      this.childNameSize = 32;
    }
  }

  aboutToAppear(): void {
    // 设置沉浸式
    this.setImmersive(this.windowStage.getMainWindowSync());
    // 监听窗口大小变化
    MediaListener.getInstance().initListen(this.getUIContext());
    MediaListener.getInstance().startListen();
  }

  aboutToDisappear(): void {
    // 取消监听
    MediaListener.getInstance().stopListen();
  }

  build() {
    Column() {
      Tabs({
        barPosition: this.tabPosition,
        controller: this.tabController,
        index: this.currentIndex
      }) {
        TabContent() {
          Column() {
            Tabs({
              barPosition: this.tabPosition,
              index: this.childIndex
            }) {
              ForEach(this.childList, (item: string, index: number) => {
                TabContent() {
                  Column() {
                    Text(`${item}的内容`)
                      .fontSize(this.childNameSize);
                  }
                  .backgroundColor('#0a59f7')
                  .justifyContent(FlexAlign.Center)
                  .width('100%')
                  .height('100%');
                }
                .tabBar(this.childTabBar(item, index));
              });
            }
            .vertical(this.isVertical)
            .backgroundColor('#ffffff')
            .onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
              this.childIndex = targetIndex;
            })
            .onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer,
              others: Array<GestureRecognizer>): GestureJudgeResult => { // 在识别器即将要成功时,根据当前组件状态,设置识别器使能状态
              if (current) {
                let target = current.getEventTargetInfo();
                if (target && current.isBuiltIn() && current.getType() == GestureControl.GestureType.PAN_GESTURE) {
                  let panEvent = event as PanGestureEvent;
                  if (!this.isVertical && panEvent && panEvent.velocityX < 0 && this.childIndex === 3) { // 内层Tabs滑动到尽头
                    return GestureJudgeResult.REJECT;
                  }
                  if (!this.isVertical && panEvent && panEvent.velocityX > 0 && this.childIndex === 0) { // 内层Tabs滑动到开头
                    return GestureJudgeResult.REJECT;
                  }
                  if (this.isVertical && panEvent && panEvent.velocityY < 0 && this.childIndex === 3) { // 内层Tabs滑动到尽头
                    return GestureJudgeResult.REJECT;
                  }
                  if (this.isVertical && panEvent && panEvent.velocityY > 0 && this.childIndex === 0) { // 内层Tabs滑动到开头
                    return GestureJudgeResult.REJECT;
                  }
                }
              }
              return GestureJudgeResult.CONTINUE;
            }, true);
          }
          .padding(16);
        }
        .backgroundColor('#ffffff')
        .tabBar(this.tabBuilder(this.tabList[0]));

        // 其他TabContent嵌套Tabs的内容,开发者可参考上面代码实现
        TabContent() {
          Tabs({
            barPosition: this.tabPosition,
            index: this.childIndex2
          }) {
            TabContent() {
              Column() {
                Text('a页签的内容')
                  .fontSize(this.childNameSize);
              }
              .justifyContent(FlexAlign.Center)
              .width('100%')
              .height('100%');
            }
            .tabBar('a');

            TabContent() {
              Column() {
                Text('b页签的内容')
                  .fontSize(this.childNameSize);
              }
              .justifyContent(FlexAlign.Center)
              .width('100%')
              .height('100%');
            }
            .tabBar('b');
          }
          .vertical(this.isVertical)
          .onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
            this.childIndex2 = targetIndex;
          })
          .onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer,
            others: Array<GestureRecognizer>): GestureJudgeResult => { // 在识别器即将要成功时,根据当前组件状态,设置识别器使能状态
            if (current) {
              let target = current.getEventTargetInfo();
              if (target && current.isBuiltIn() && current.getType() == GestureControl.GestureType.PAN_GESTURE) {
                let panEvent = event as PanGestureEvent;
                if (!this.isVertical && panEvent && panEvent.velocityX < 0 && this.childIndex2 === 1) { // 内层Tabs滑动到尽头
                  return GestureJudgeResult.REJECT;
                }
                if (!this.isVertical && panEvent && panEvent.velocityX > 0 && this.childIndex2 === 0) { // 内层Tabs滑动到开头
                  return GestureJudgeResult.REJECT;
                }
                if (this.isVertical && panEvent && panEvent.velocityY < 0 && this.childIndex2 === 1) { // 内层Tabs滑动到尽头
                  return GestureJudgeResult.REJECT;
                }
                if (this.isVertical && panEvent && panEvent.velocityY > 0 && this.childIndex2 === 0) { // 内层Tabs滑动到开头
                  return GestureJudgeResult.REJECT;
                }
              }
            }
            return GestureJudgeResult.CONTINUE;
          }, true);
        }
        .tabBar(this.tabBuilder(this.tabList[1]));

        // ...
      }
      .backgroundColor('#f1f3f5')
      .barHeight(this.isVertical ? '100%' : this.imgWidth + 80)
      .barWidth(this.isVertical ? this.imgWidth + 80 : '100%')
      .vertical(this.isVertical)
      .onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
        this.currentIndex = targetIndex;
      });
    }
    .width('100%')
    .height('100%');
  }

  // 设置沉浸式
  setImmersive(mainWindow: window.Window) {
    try {
      mainWindow.setWindowLayoutFullScreen(true)
        .then(() => {
          console.info(`Succeeded in setting immersive mode.`);
        })
        .catch((err: BusinessError) => {
          console.error(`Failed to set immersive mode. Code: ${err.code}, message: ${err.message}`);
        });
      mainWindow.setWindowDecorVisible(false);
    } catch (error) {
      let err = error as BusinessError;
      console.error(`Failed to set immersive type. Code: ${err.code}, message: ${err.message}`);
    }
  }
}

在EntryAbility文件设置:

  onWindowStageCreate(windowStage: window.WindowStage): void {
    // Main window is created, set main page for this ability
    hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
    AppStorage.setOrCreate('windowStage',windowStage)
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
    });
  }

我也在研究学习这个功能。

在HarmonyOS NEXT中,使用ArkTS深度定制Tabs组件:通过barMode属性设置滚动/固定模式,利用TabContentbuilder自定义每项布局(图标、文本等),结合@Extend装饰器扩展样式。配合GridRow/GridCol实现多端自适应,使用@State管理内嵌组件的动态切换。

实现WPS式跨设备导航栏的关键在于 完全自定义 TabBar,并结合断点做动态形态切换。下面是一个可运行的深度定制 Demo,涵盖:自定义图标+文字 TabBar、大屏侧边栏折叠仅显示图标、小屏底部栏。

// 自定义TabBar构建器:每个Tab的样式完全由你控制
@Builder
function CustomTabBar(title: string, icon: Resource, isLarge: boolean, isCollapsed: boolean) {
  Column() {
    Image(icon).width(24).height(24)
    if (isLarge && !isCollapsed) {
      Text(title).fontSize(12).margin({ top: 4 })
    }
  }
  .justifyContent(FlexAlign.Center)
  .width('100%')
  .padding({ top: 8, bottom: 8 })
}

@Entry
@Component
struct AdvancedTabs {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md';
  @State isCollapsed: boolean = false; // 控制侧边栏是否仅图标
  private controller: TabsController = new TabsController();

  build() {
    Column() {
      // 大屏时显示折叠按钮
      if (this.isLarge()) {
        Button(this.isCollapsed ? '展开' : '折叠')
          .margin(10)
          .onClick(() => this.isCollapsed = !this.isCollapsed)
      }
      Row() {
        Tabs({
          barPosition: this.isLarge() ? BarPosition.Start : BarPosition.Start,
          controller: this.controller
        }) {
          TabContent() {
            Text('文档页面').fontSize(24)
          }.tabBar(this.CustomTabBar('文档', $r('app.media.ic_document'), this.isLarge(), this.isCollapsed))

          TabContent() {
            Text('表格页面').fontSize(24)
          }.tabBar(this.CustomTabBar('表格', $r('app.media.ic_sheet'), this.isLarge(), this.isCollapsed))

          TabContent() {
            Text('演示页面').fontSize(24)
          }.tabBar(this.CustomTabBar('演示', $r('app.media.ic_slide'), this.isLarge(), this.isCollapsed))
        }
        .vertical(this.isLarge())  // 大屏垂直排列
        .barWidth(this.isLarge() ? (this.isCollapsed ? 72 : 160) : '100%')
        .barMode(BarMode.Fixed)
        .layoutWeight(1)
      }
      .width('100%')
      .height('100%')
    }
  }

  isLarge(): boolean {
    return this.currentBreakpoint === 'lg';
  }
}

无法仅靠属性实现的深度定制点:

  1. 动态折叠侧边栏:大屏下通过 isCollapsed 控制 barWidth,并在自定义 TabBar 中根据状态隐藏文字,只显示图标,实现 WPS 左侧导航的收缩效果。
  2. 每个 Tab 的完全自定义样式:图标、文字、角标、背景色、圆角等均可自由绘制,比如激活态变蓝、添加未读红点。
  3. 跨设备形态切换:小屏时 vertical=false 底部栏,大屏时 vertical=true 左侧栏,且折叠后仅显示图标,这是标准 Tabs 属性无法直接实现的组合效果。
  4. 集成浮层或弹出菜单:在自定义 TabBar 中可响应长按、双击等事件,弹出二级菜单,完全不受 Tabs 内置交互限制。

该 Demo 直接基于你的断点系统,省去基础工程介绍,展示了“必须自定义才能实现”的深层次响应式导航栏,复现类似 WPS 的多态底座。

回到顶部