【HarmonyOSNext】自定义Tabs
- 人工智能
- 2025-09-11 16:54:01

背景
项目中Tabs的使用可以说是特别的频繁,但是官方提供的Tabs使用起来,存在tab选项卡切换动画滞后的问题。 原始动画无法满足产品的UI需求,因此,这篇文章将实现下面页面滑动,tab选项卡实时滑动的动画效果。
实现逻辑 需求讲解 需要实现固定宽度下,放下6个选项卡。在没有选择时宽度均匀分配,选中时显示图标并且增加宽度。实现下方内容区域滑动时,上面选项卡实时跳动。实现动画效果,使整体操作更加流畅。 实现思路 1. 选项卡 选项卡使用Row布局组件+layoutWeight属性,来实现平均布局。通过选项卡的选择索引来实现是否选中判断。选中时,layoutWeight值为1.5;没有选中时,layoutWeight值为1.使用animation属性,只要layoutWeight值变化时,可以触发动画。在外包裹的布局容器中,添加onAreaChange事件,用来计算整体Tab组件的宽度。 Row() { Text(name) .fontSize(16) .fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal) .textAlign(TextAlign.Center) .animation({ duration: 300 }) Image($r('app.media.send')) .width(14) .height(14) .margin({ left: 2 }) .visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None) .animation({ duration: 300 }) } .justifyContent(FlexAlign.Center) .layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1) .animation({ duration: 300 }) 2. 定位器 使用Rect定义背景的形状和颜色+Stack布局+position属性,实现定位器的移动。position属性中通过Left值的变化来实现Rect的移动。但是在swiper的滑动中会出现滑动一点然后松开的情况,因此,需要两个值同时在实现中间的移动过程。 Stack() { Rect() .height(30) .stroke(Color.Black) .radius(10) .width(this.FirstWidth) .fill("#bff9f2") .position({ left: this.IndicatorLeftOffset + this.IndicatorOffset, bottom: 0 }) .animation({ duration: 300, curve: Curve.LinearOutSlowIn }) } .width("100%") .alignRules({ center: { anchor: "Tabs", align: VerticalAlign.Center } }) 3.主要内容区 使用Swiper组件加载对应的组件,这里需要注意的是,Demo没有考虑到内容比较多的优化方案,可以设置懒加载方案来实现性能的提升。onAnimationStart事件,实现监测控件是向左移动还是向右移动,并且修改IndicatorLeftOffset偏移值。onAnimationEnd事件,将中间移动过程值IndicatorOffset恢复成0。onGestureSwipe事件,监测组件的实时滑动,这个事件在onAnimationStart和onAnimationEnd事件之前执行,执行完后才会执行onAnimationStart事件。因此,这个方法需要实时修改定位器的偏移数值。偏移数值是通过swiper的移动数值和整体宽度的比例方式进行计算,松手后的偏移方向,由onAnimationStart和onAnimationEnd事件来确定最终的距离 Swiper(this.SwiperController) { ForEach(this.TabNames, (name: string, index: number) => { Column() { Text(`${name} - ${index}`) .fontSize(24) .fontWeight(FontWeight.Bold) } .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) .height("100%") .width("100%") }) } .onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => { if (targetIndex > index) { this.IndicatorLeftOffset += this.OtherWidth; } else if (targetIndex < index) { this.IndicatorLeftOffset -= this.OtherWidth; } this.IndicatorOffset = 0 this.SelectedTabIndex = targetIndex }) .onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => { this.IndicatorOffset = 0 }) .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => { let move: number = this.GetOffset(extraInfo.currentOffset); //这里需要限制边缘情况 if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) || (this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) { return; } this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move; }) .onAreaChange((oldValue: Area, newValue: Area) => { let width = newValue.width.valueOf() as number; this.SwiperWidth = width; }) .curve(Curve.LinearOutSlowIn) .loop(false) .indicator(false) .width("100%") .id("MainContext") .alignRules({ top: { anchor: "Tabs", align: VerticalAlign.Bottom }, bottom: { anchor: "__container__", align: VerticalAlign.Bottom } }) 代码文件 里面涉及到资源的小图标,可以自己区定义的,文章就不提供了。 @Entry @ComponentV2 struct Index { /** * 标头名称集合 */ @Local TabNames: string[] = ["飞机", "铁路", "自驾", "地铁", "公交", "骑行"] /** * Tab选择索引 */ @Local SelectedTabIndex: number = 0 /** * 标点移动距离 */ @Local IndicatorLeftOffset: number = 0 /** * 标点在swiper的带动下移动的距离 */ @Local IndicatorOffset: number = 0 /** * 第一个宽度 */ @Local FirstWidth: number = -1 /** * 其他的宽度 */ @Local OtherWidth: number = -1 /** * Swiper控制器 */ @Local SwiperController: SwiperController = new SwiperController() /** * Swiper容器宽度 */ @Local SwiperWidth: number = 0 build() { RelativeContainer() { Stack() { Rect() .height(30) .stroke(Color.Black) .radius(10) .width(this.FirstWidth) .fill("#bff9f2") .position({ left: this.IndicatorLeftOffset + this.IndicatorOffset, bottom: 0 }) .animation({ duration: 300, curve: Curve.LinearOutSlowIn }) } .width("100%") .alignRules({ center: { anchor: "Tabs", align: VerticalAlign.Center } }) Row() { ForEach(this.TabNames, (name: string, index: number) => { Row() { Text(name) .fontSize(16) .fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal) .textAlign(TextAlign.Center) .animation({ duration: 300 }) Image($r('app.media.send')) .width(14) .height(14) .margin({ left: 2 }) .visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None) .animation({ duration: 300 }) } .justifyContent(FlexAlign.Center) .layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1) .animation({ duration: 300 }) .onClick(() => { this.SelectedTabIndex = index; this.SwiperController.changeIndex(index, false); animateTo({ duration: 500, curve: Curve.LinearOutSlowIn }, () => { this.IndicatorLeftOffset = this.OtherWidth * index; }) }) }) } .width("100%") .height(30) .id("Tabs") .onAreaChange((oldValue: Area, newValue: Area) => { let tabWidth = newValue.width.valueOf() as number; this.FirstWidth = 1.5 * tabWidth / (this.TabNames.length + 0.5); this.OtherWidth = tabWidth / (this.TabNames.length + 0.5); }) Swiper(this.SwiperController) { ForEach(this.TabNames, (name: string, index: number) => { Column() { Text(`${name} - ${index}`) .fontSize(24) .fontWeight(FontWeight.Bold) } .alignItems(HorizontalAlign.Center) .justifyContent(FlexAlign.Center) .height("100%") .width("100%") }) } .onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => { if (targetIndex > index) { this.IndicatorLeftOffset += this.OtherWidth; } else if (targetIndex < index) { this.IndicatorLeftOffset -= this.OtherWidth; } this.IndicatorOffset = 0 this.SelectedTabIndex = targetIndex }) .onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => { this.IndicatorOffset = 0 }) .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => { let move: number = this.GetOffset(extraInfo.currentOffset); //这里需要限制边缘情况 if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) || (this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) { return; } this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move; }) .onAreaChange((oldValue: Area, newValue: Area) => { let width = newValue.width.valueOf() as number; this.SwiperWidth = width; }) .curve(Curve.LinearOutSlowIn) .loop(false) .indicator(false) .width("100%") .id("MainContext") .alignRules({ top: { anchor: "Tabs", align: VerticalAlign.Bottom }, bottom: { anchor: "__container__", align: VerticalAlign.Bottom } }) } .height('100%') .width('100%') .padding(10) } /** * 需要注意的点,当前方法仅计算偏移值,不带方向 * @param swiperOffset * @returns */ GetOffset(swiperOffset: number): number { let swiperMoveRatio: number = Math.abs(swiperOffset / this.SwiperWidth); let tabMoveValue: number = swiperMoveRatio >= 1 ? this.OtherWidth : this.OtherWidth * swiperMoveRatio; return tabMoveValue; } } 总结这里实现了新的Tab选项卡的定义。但是没有进行高度封装,想法是方便读者理解组件的使用逻辑,而不是直接提供给读者进行调用。希望这篇文章可以帮助到你~~
【HarmonyOSNext】自定义Tabs由讯客互联人工智能栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“【HarmonyOSNext】自定义Tabs”