Tabs滑动

Tabs组件在用户交互方面提供了丰富的特性,其中与滑动动作相关的交互尤为常见。下文将介绍几种与Tabs和滑动动作相关的特性。

双层Tabs嵌套滑动

在应用开发中,开发者经常遇到多层Tabs嵌套使用的场景。如果父子Tabs组件均需滑动切换时,开发者需要对父子Tabs的滑动切换行为进行约束,以避免冲突。通常做法是,让滑动操作优先切换子Tabs页签,当子Tabs页签切换到最后一个后,再触发父Tabs的页签切换。

在这里插入图片描述

实现原理

可以通过PanGesture结合TabsController的changeIndex()方法实现双层Tabs的切换。具体操作为:开启子Tabs的滑动切换功能,同时关闭父Tabs的滑动切换。在子Tabs的第一个或者最后一个页面上添加PanGesture事件处理函数,用于判断滑动方向,并根据滑动方向使用TabsController的changeIndex()方法切换到父Tabs的相应页签。这样一来,子Tabs的中间页签滑动时,仅会触发子Tabs页签的切换,而最后一个页签的滑动则会通过changeIndex()方法间接触发父Tabs页签的切换。

在这里插入图片描述

开发步骤

  1. 外层Tabs组件中定义TabsController属性,以及内层Tabs双向绑定的状态属性变量switchNext及其监听函数。当监听到需要切换页签时,利用TabsController切换到对应页签。因为本示例外层Tabs和内层Tabs封装到不同的自定义组件中了,所以需要@Link修饰的switchNext变量作为父子组件的交互媒介。

    @Component
    export default struct OutTabsComponent {
      // ...
      @State @Watch('onchangeSwitchNext') switchNext: boolean = false;
      // ...
      onchangeSwitchNext() {
        if (this.switchNext) {
          this.switchNext = false;
          this.tabsController.changeIndex(1);
        }
      }
      // ...
      build() {
        Tabs({
          // ...
          controller: this.tabsController,
        }) {
          TabContent() {
            InTabsComponent({ switchNext: this.switchNext })
          }.tabBar(this.tabBuilder(0, $r('app.string.out_bar_text_home'), $r('sys.symbol.house')))
          // ...
        }
        // ...
      }
    }
    
  2. 内层Tabs组件在最后一个TabContent中注册滑动事件处理函数,监听向左滑动作,触发时修改switchNext变量值传递给外层Tabs组件触发切换。

    @Component
    export default struct InTabsComponent {
      // ...
      @Link switchNext: boolean;
      // ...
      build() {
        // ...
                Tabs({
                  // ...
                }) {
                  // bind selected tabs to ui
                  ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
                    if (index === this.selectTabsViewModel.selectedTabs.length - 1) {
                      TabContent() {
                        // ...
                      }
                      .tabBar(this.tabBuilder(index, tab))
                      .gesture(PanGesture(new PanGestureOptions({ direction: PanDirection.Left })).onActionStart(() => {
                        this.switchNext = true;
                      }))
                      // ...
                    } else {
                      // ...
                    }
                  }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
                }
                // ...
      }
    }
    
  3. 注意滑动切换在自定义切换动画场景下失效,故需要注释掉切换动画函数注册。

    Tabs({
      barPosition: BarPosition.Start,
      controller: this.subsController,
      barModifier: this.tabBarModifier
    }) {
      // ...
    }
    // add animation function
    .customContentTransition(this.customContentTransition) // comment out to slide to switch
    

可滚动Tabs页签栏+更多按钮

可滚动页签栏通常设置在顶部或侧边导航栏,当内容分类较多,屏幕显示区域无法完全展示所有分类页签时,该页签栏允许用户通过滚动来访问隐藏的页签内容。

在这里插入图片描述

实现原理

通过将Tabs组件的barMode属性设置为BarMode.Scrollable,可以实现可滚动的页签栏。若要实现添加更多按钮的效果,可以通过Stack布局结合barModifier功能实现。具体做法是在Tabs组件的TabBar位置的末端上层利用Stack布局添加更多按钮,并且点击该按钮时可以弹出窗口,在弹窗中自定义需要显示的页签。

开发步骤

设置barMode属性为BarMode.Scrollable,并利用Stack布局在TabBar右上角添加更多按钮。

Stack({ alignContent: Alignment.TopEnd }) {
  Row() {
    Image($r('app.media.more'))
      // ...
      .onClick(() => {
        this.showSelectTabsComponent = !this.showSelectTabsComponent;
      })
  }
  // ...
  .zIndex(1)
  .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();
    }
  })
  Column() {
    Tabs({
      // ...
    }) {
      // ...
    }
    // ...
    .barMode(BarMode.Scrollable)
    // ...
  }
  .width(Constants.FULL_WIDTH)
  .height(Constants.FULL_HEIGHT)
  .backgroundColor($r('app.color.out_tab_bar_background_color'))
}

禁用TabContent左右滑动

默认情况下,导航栏支持滑动切换。当存在多级导航栏嵌套或导航栏中的其他组件需要占用滑动动作时,为避免滑动响应冲突,开发者可选择禁用Tabs组件的滑动切换功能。通过将Tabs组件的scrollable属性设置为false,可以禁止通过滑动TabContent来切换页签。同样,若想禁用边缘回弹效果,可将edgeEffect的值设置为EdgeEffect.None。

示例代码:

build() {
  Tabs({
    // ...
  }) {
    // ...
  }
  // ...
  .scrollable(true) // false to disable scroll to switch
  // .edgeEffect(EdgeEffect.None) // disables edge springback
  // ...
}

Tabs页签加载/更新

在使用Tabs组件进行开发时,特别是当Tabs组件作为二级导航使用时,业务需求往往需要对Tabs的标签页进行更精细的控制。下文将介绍几种定制标签页显示逻辑的场景。

显示指定页签与预加载

Tabs组件的TabContent默认在首次切换到该标签页时加载。如果TabContent中的内容或初始化逻辑较为复杂,加载速度较慢,则会影响标签页切换的流畅性,进而影响用户体验。此时,如果应用能在切换前预加载相应的标签页,将显著提升使用流畅度。

在这里插入图片描述

实现原理

通过TabController的preloadItem()方法可以预加载指定子节点。该方法参数为需要预加载的index数组,无参调用此方法时,会一次性加载所有指定的子节点。因此,为了性能考虑,建议分批加载子节点。代码示例这里做法是当切换到某页签时,预加载所选页签左右两侧的页签内容。

开发步骤

定义subsController属性,并在Tabs的onChange函数中调用preloadItem()预加载当前页签两侧页签。

@Component
export default struct InTabsComponent {
  // ...
  private subsController: TabsController = new TabsController();
  // ...
  build() {
    // ...
            Tabs({
              // ...
              controller: this.subsController,
              // ...
            }) {
              // ...
            }
            // ...
            .onChange((index: number) => {
              this.focusIndex = index;
              this.tabBarItemScroller.scrollToIndex(index, true, ScrollAlign.CENTER);
              // preload the left and right item
              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);
            })
            // ...
  }
}

切换到指定页签

Tabs组件除了自带的滑动切换和点击切换功能外,还提供了两种可编程方式来切换页签。第一种是通过调用TabsController的changeIndex()方法,切换到指定的index;第二种是定义一个由@State修饰的变量currentIndex,并将其绑定到Tabs,通过修改currentIndex的值来触发页签切换。
在这里插入图片描述

开发步骤

定义currentIndex变量和tabController属性,并绑定到Tabs。在按钮onClick函数中,调用tabController.changeIndex()或者直接修改currentIndex变量切换页签。

@Component
export default struct SwitchTabComponent {
  // ...
  @State currentIndex: number = 0;
  private tabController: TabsController = new TabsController();

  // ...

  build() {
    Column() {
      Row() {
        Button('Previous Tab')
          .onClick(() => {
            this.tabController.changeIndex((this.currentIndex + 3) % 4); // call tabController.changeIndex() to switch tab
          })
           // ...

        Button('Next Tab')
          .onClick(() => {
            this.currentIndex = (this.currentIndex + 1) % 4; // change currentIndex to switch tab
          })
           // ...
      }

      Tabs({
        controller: this.tabController,
        index: $$this.currentIndex // use $$ for two-way data binding
      }) {
        // ...
      }
    }

  }
}

增删Tabs页签

在日常的应用开发中,经常需要实现用户自定义选择频道的功能。通常,这些自定义选择的频道会通过Tabs组件来展示,因此需要动态地更新Tabs的页签。本示例设计了一对父子组件来演示这一功能。父组件负责显示页签及其内容,并在页签栏的最右侧设置一个“更多”按钮。点击此按钮会弹出一个窗口,供用户选择需要显示的页签。该弹窗内容由子组件提供,关闭弹窗后,父组件的页签将被更新。

在这里插入图片描述

实现原理

定义selectTabsViewModel对象,其中的数组allTabs表示所有可选择页签,数组selectedTabs表示选中的需要显示的页签,并通过@Link绑定到父组件InTabComponent和子组件SelectTabsComponent中。子组件SelectTabsComponent作为一个弹窗用于选择需要显示的页签。选择完成后,关闭弹窗并更新 selectTabsViewModel对象中的选中页签数组 selectedTabs,以触发父组件InTabComponent的页签更新。

在这里插入图片描述

开发步骤

  1. 定义SelectTabsViewModel类,包含所有可选择页签数组allTabs属性,和需要显示的页签数组selectedTabs属性,及更新显示页签数组的方法updateSelectedTabs()。

    @Observed
    class TabItemArray extends Array<TabItemViewModel> {
    }
    
    @Observed
    export default class SelectTabsViewModel {
      allTabs: TabItemArray = new TabItemArray();
      selectedTabs: TabItemArray = new TabItemArray();
      // ...
    
      async loadTabs(ctx: Context) {
        // ...
      }
    
      // apply changes to the selected tabs
      updateSelectedTabs() {
        let tempTabs: TabItemViewModel[] = [];
        for (let tab of this.allTabs) {
          if (tab.isChecked) {
            tempTabs.push(tab);
          }
        }
        this.selectedTabs = tempTabs;
      }
    }
    
  2. 在InTabsComponent中定义selectTabsViewModel属性,并且在aboutToAppear()方法中初始化。

    @Component
    export default struct InTabsComponent {
      @State selectTabsViewModel: SelectTabsViewModel = new SelectTabsViewModel();
      // ...
      async aboutToAppear() {
        // ...
    
        await this.selectTabsViewModel.loadTabs(this.ctx);
        // ...
      }
      // ...
    }
    
  3. 利用ForEach组件将selectTabsViewModel.selectedTabs属性绑定到Tabs的页签上。

    Tabs({
      // ...
    }) {
      // bind selected tabs to ui
      ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
        if (index === this.selectTabsViewModel.selectedTabs.length - 1) {
          TabContent() {
            // ...
          }
          .tabBar(this.tabBuilder(index, tab))
          // ...
        } else {
          // ...
        }
      }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
    }
    
  4. 在更多按钮的弹窗中初始化SelectTabsComponent,并将selectTabsViewModel属性作为双向绑定属性传入。在关闭弹窗处理函数中调用selectTabsViewModel.updateSelectedTabs()方法,更新需要显示的组件。

    @Builder
    sheetBuilder() {
      //select tabs to show
      SelectTabsComponent({ selectTabsViewModel: this.selectTabsViewModel })
    }
    build() {
      Scroll() {
        Column() {
          BannerComponent()
    
          Stack({ alignContent: Alignment.TopEnd }) {
            Row() {
              Image($r('app.media.more'))
                // ...
                .onClick(() => {
                  this.showSelectTabsComponent = !this.showSelectTabsComponent;
                })
            }
            // ...
            .zIndex(1)
            .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();
              }
            })
            // ...
          }
        }
      }
      // ...
    }
    
  5. 在SelectTabsComponent中将selectTabsViewModel.allTabs属性渲染成toggle组件,并且注册toggle组件的切换处理函数onChange(),在其中修改该页签的选择状态isChecked属性,供更新显示页签方法selectTabsViewModel.updateSelectedTabs()使用。

    @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 }) {
                  // ...
                }
                // ...
                .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)
      }
    }
    

Tabs切换动效

TabContent切换动画

Tabs 自带的页签切换动画为平移动画。若开发者需实现更高级的动画效果,可通过Tabs提供的API实现自定义动画。

在这里插入图片描述

实现原理

使用customContentTransition()函数来自定义Tabs页面的切换动画。本场景采用属性动画实现,开发者可以定义由@State修饰的可动画属性,并在build()方法中将这些属性绑定到对应的页签上。这里,淡入淡出动画选用了TabContent的尺寸属性scale和透明度属性opacity作为生成动画属性。然后,在customContentTransition()函数中,设置动画的起始帧和结束帧对应的可动画属性值,系统将自动补全中间帧从而生成动画。

在这里插入图片描述

说明

  • 使用自定义切换动画时,Tabs组件的默认切换动画将被禁用,且页面将无法通过手势滑动切换。
  • 将customContentTransition设置为undefined表示不使用自定义切换动画,继续使用组件自带的默认切换动画。
  • 当前自定义切换动画不支持中途打断。
  • 目前,自定义切换动画仅支持以下两种触发场景:点击页签或通过调用TabsController.changeIndex()方法。

开发步骤

  1. 定义动画所需用到的属性数组。

    @Component
    export default struct InTabsComponent {
      // ...
      @State scaleList: number[] = [];
      @State opacityList: number[] = [];
      // ...
    }
    
  2. 将属性数组绑定到对应的页签上。

    Tabs({
      // ...
    }) {
      // bind selected tabs to ui
      ForEach(this.selectTabsViewModel.selectedTabs, (tab: TabItemViewModel, index: number) => {
        if (index === this.selectTabsViewModel.selectedTabs.length - 1) {
          TabContent() {
            // ...
          }
          // ...
          // bind animation properties
          .opacity(this.opacityList[index])
          .scale({
            x: this.scaleList[index], y: this.scaleList[index]
          })
        } else {
          // ...
        }
      }, (tab: TabItemViewModel, index: number) => index + '_' + JSON.stringify(tab))
    }
    
  3. 定义Tabs的自定义转场函数。

    @Component
    export default struct InTabsComponent {
      // ...
      @State scaleList: number[] = [];
      @State opacityList: number[] = [];
      // ...
      private animateDuration: number = 1000;
      private animateTimeout: number = 1000;
      private customContentTransition: (from: number, to: number) => TabContentAnimatedTransition =
        (from: number, to: number) => {
          let tabContentAnimatedTransition = {
            timeout: this.animateTimeout,
            transition: (proxy: TabContentTransitionProxy) => {
              // start frame
              this.scaleList[from] = 1.0;
              this.scaleList[to] = 0.5;
              this.opacityList[from] = 1.0;
              this.opacityList[to] = 0.5;
              this.getUIContext().animateTo({
                duration: this.animateDuration,
                onFinish: () => {
                  proxy.finishTransition();
                }
              }, () => {
                // end frame
                this.scaleList[from] = 0.5;
                this.scaleList[to] = 1.0;
                this.opacityList[from] = 0.5;
                this.opacityList[to] = 1.0;
              });
            }
          } as TabContentAnimatedTransition;
          return tabContentAnimatedTransition;
        };
    
      // ...
    }
    
  4. 将转场函数作为参数传递给Tabs的customContentTransition()方法。

    Tabs({
      barPosition: BarPosition.Start,
      controller: this.subsController,
      barModifier: this.tabBarModifier
    }) {
      // ...
    }
    // add animation function
    .customContentTransition(this.customContentTransition) // comment out to slide to switch
    

自定义Tabs页签切换联动

在自定义页签样式中,页签的选中和非选中状态显示样式不同时,页签的样式依赖于Tabs组件的切换动作。这种情况下,需要实现Tabs页签的联动,页签切换时,页签样式自动变更。

在这里插入图片描述

实现原理

可以通过onChange事件,在切换页签时自定义TabBar和TabContent的联动效果。具体做法是定义一个由@State修饰的变量currentIndex,用于标识当前显示的页签索引。然后,利用onChange()方法注册处理函数,并在处理函数中更新currentIndex,确保其与当前选择的页签的索引一致。在页签样式的实现中,通过判断currentIndex变量与各页签索引是否相等来决定显示的样式,同时currentIndex属性的变化会触发页签样式的更新。

开发步骤

定义currentIndex属性,tabBuilder方法,并在onChange函数中更新currentIndex属性值。

@Component
export default struct OutTabsComponent {
  @State currentIndex: number = 0;
  // ...
  @Builder
  tabBuilder(index: number, name: string | Resource, icon: Resource) {
    Column() {
      // set special styles if selected

      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'))
    }
    // ...
  }
  build() {
    Tabs({
      // ...
    }) {
      // ...
    }
    // ...
    .onChange((index: number) => {
      this.currentIndex = index;
    })
    // ...
  }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐