#HarmonyOS NEXT体验官# 自定义Tabs 原创

奥尼5354
发布于 2025-3-10 23:01
浏览
0收藏


背景

项目中Tabs的使用可以说是特别的频繁,但是官方提供的Tabs使用起来,存在tab选项卡切换动画滞后的问题。 

#HarmonyOS NEXT体验官# 自定义Tabs-鸿蒙开发者社区0900086000300134184.20201216095126.86523331460016843504112994983392.png

 原始动画无法满足产品的UI需求,因此,这篇文章将实现下面页面滑动,tab选项卡实时滑动的动画效果。

#HarmonyOS NEXT体验官# 自定义Tabs-鸿蒙开发者社区0900086000300134184.20201216095126.86523331460016843504112994983392.png

实现逻辑

需求讲解

  • 需要实现固定宽度下,放下6个选项卡。
  • 在没有选择时宽度均匀分配,选中时显示图标并且增加宽度。
  • 实现下方内容区域滑动时,上面选项卡实时跳动。
  • 实现动画效果,使整体操作更加流畅。

实现思路

1. 选项卡

  • 选项卡使用Row布局组件+layoutWeight属性,来实现平均布局。通过选项卡的选择索引来实现是否选中判断。
  • 选中时,layoutWeight值为1.5;没有选中时,layoutWeight值为1.
  • 使用animation属性,只要layoutWeight值变化时,可以触发动画。
  • 在外包裹的布局容器中,添加onAreaChange事件,用来计算整体Tab组件的宽度。
  1. ​Row() {​
  2. ​Text(name)​
  3. ​.fontSize(16)​
  4. ​.fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)​
  5. ​.textAlign(TextAlign.Center)​
  6. ​.animation({ duration: 300 })​
  7. ​Image($r('app.media.send'))​
  8. ​.width(14)​
  9. ​.height(14)​
  10. ​.margin({ left: 2 })​
  11. ​.visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)​
  12. ​.animation({ duration: 300 })​
  13. ​}​
  14. ​.justifyContent(FlexAlign.Center)​
  15. ​.layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)​
  16. ​.animation({ duration: 300 })​

2. 定位器

  • 使用Rect定义背景的形状和颜色+Stack布局+position属性,实现定位器的移动。
  • position属性中通过Left值的变化来实现Rect的移动。但是在swiper的滑动中会出现滑动一点然后松开的情况,因此,需要两个值同时在实现中间的移动过程。
  1. ​Stack() {​
  2. ​Rect()​
  3. ​.height(30)​
  4. ​.stroke(Color.Black)​
  5. ​.radius(10)​
  6. ​.width(this.FirstWidth)​
  7. ​.fill("#bff9f2")​
  8. ​.position({​
  9. ​left: this.IndicatorLeftOffset + this.IndicatorOffset,​
  10. ​bottom: 0​
  11. ​})​
  12. ​.animation({ duration: 300, curve: Curve.LinearOutSlowIn })​
  13. ​}​
  14. ​.width("100%")​
  15. ​.alignRules({​
  16. ​center: { anchor: "Tabs", align: VerticalAlign.Center }​
  17. ​})​

3.主要内容区

  • 使用Swiper组件加载对应的组件,这里需要注意的是,Demo没有考虑到内容比较多的优化方案,可以设置懒加载方案来实现性能的提升。
  • onAnimationStart事件,实现监测控件是向左移动还是向右移动,并且修改IndicatorLeftOffset偏移值。
  • onAnimationEnd事件,将中间移动过程值IndicatorOffset恢复成0。
  • onGestureSwipe事件,监测组件的实时滑动,这个事件在onAnimationStart和onAnimationEnd事件之前执行,执行完后才会执行onAnimationStart事件。因此,这个方法需要实时修改定位器的偏移数值。
  • 偏移数值是通过swiper的移动数值和整体宽度的比例方式进行计算,松手后的偏移方向,由onAnimationStart和onAnimationEnd事件来确定最终的距离
  1. ​Swiper(this.SwiperController) {​
  2. ​ForEach(this.TabNames, (name: string, index: number) => {​
  3. ​Column() {​
  4. ​Text(`${name} - ${index}`)​
  5. ​.fontSize(24)​
  6. ​.fontWeight(FontWeight.Bold)​
  7. ​}​
  8. ​.alignItems(HorizontalAlign.Center)​
  9. ​.justifyContent(FlexAlign.Center)​
  10. ​.height("100%")​
  11. ​.width("100%")​
  12. ​})​
  13. ​}​
  14. ​.onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {​
  15. ​if (targetIndex > index) {​
  16. ​this.IndicatorLeftOffset += this.OtherWidth;​
  17. ​} else if (targetIndex < index) {​
  18. ​this.IndicatorLeftOffset -= this.OtherWidth;​
  19. ​}​
  20. ​this.IndicatorOffset = 0​
  21. ​this.SelectedTabIndex = targetIndex​
  22. ​})​
  23. ​.onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {​
  24. ​this.IndicatorOffset = 0​
  25. ​})​
  26. ​.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {​
  27. ​let move: number = this.GetOffset(extraInfo.currentOffset);​
  28. ​//这里需要限制边缘情况​
  29. ​if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||​
  30. ​(this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {​
  31. ​return;​
  32. ​}​
  33. ​this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;​
  34. ​})​
  35. ​.onAreaChange((oldValue: Area, newValue: Area) => {​
  36. ​let width = newValue.width.valueOf() as number;​
  37. ​this.SwiperWidth = width;​
  38. ​})​
  39. ​.curve(Curve.LinearOutSlowIn)​
  40. ​.loop(false)​
  41. ​.indicator(false)​
  42. ​.width("100%")​
  43. ​.id("MainContext")​
  44. ​.alignRules({​
  45. ​top: { anchor: "Tabs", align: VerticalAlign.Bottom },​
  46. ​bottom: { anchor: "__container__", align: VerticalAlign.Bottom }​
  47. ​})​

代码文件

  • 里面涉及到资源的小图标,可以自己区定义的,文章就不提供了。
  1. ​@Entry​
  2. ​@ComponentV2​
  3. ​struct Index {​
  4. ​/**​
  5. ​* 标头名称集合​
  6. ​*/​
  7. ​@Local TabNames: string[] = ["飞机", "铁路", "自驾", "地铁", "公交", "骑行"]​
  8. ​/**​
  9. ​* Tab选择索引​
  10. ​*/​
  11. ​@Local SelectedTabIndex: number = 0​
  12. ​/**​
  13. ​* 标点移动距离​
  14. ​*/​
  15. ​@Local IndicatorLeftOffset: number = 0​
  16. ​/**​
  17. ​* 标点在swiper的带动下移动的距离​
  18. ​*/​
  19. ​@Local IndicatorOffset: number = 0​
  20. ​/**​
  21. ​* 第一个宽度​
  22. ​*/​
  23. ​@Local FirstWidth: number = -1​
  24. ​/**​
  25. ​* 其他的宽度​
  26. ​*/​
  27. ​@Local OtherWidth: number = -1​
  28. ​/**​
  29. ​* Swiper控制器​
  30. ​*/​
  31. ​@Local SwiperController: SwiperController = new SwiperController()​
  32. ​/**​
  33. ​* Swiper容器宽度​
  34. ​*/​
  35. ​@Local SwiperWidth: number = 0​
  36. ​build() {​
  37. ​RelativeContainer() {​
  38. ​Stack() {​
  39. ​Rect()​
  40. ​.height(30)​
  41. ​.stroke(Color.Black)​
  42. ​.radius(10)​
  43. ​.width(this.FirstWidth)​
  44. ​.fill("#bff9f2")​
  45. ​.position({​
  46. ​left: this.IndicatorLeftOffset + this.IndicatorOffset,​
  47. ​bottom: 0​
  48. ​})​
  49. ​.animation({ duration: 300, curve: Curve.LinearOutSlowIn })​
  50. ​}​
  51. ​.width("100%")​
  52. ​.alignRules({​
  53. ​center: { anchor: "Tabs", align: VerticalAlign.Center }​
  54. ​})​
  55. ​Row() {​
  56. ​ForEach(this.TabNames, (name: string, index: number) => {​
  57. ​Row() {​
  58. ​Text(name)​
  59. ​.fontSize(16)​
  60. ​.fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)​
  61. ​.textAlign(TextAlign.Center)​
  62. ​.animation({ duration: 300 })​
  63. ​Image($r('app.media.send'))​
  64. ​.width(14)​
  65. ​.height(14)​
  66. ​.margin({ left: 2 })​
  67. ​.visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)​
  68. ​.animation({ duration: 300 })​
  69. ​}​
  70. ​.justifyContent(FlexAlign.Center)​
  71. ​.layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)​
  72. ​.animation({ duration: 300 })​
  73. ​.onClick(() => {​
  74. ​this.SelectedTabIndex = index;​
  75. ​this.SwiperController.changeIndex(index, false);​
  76. ​animateTo({ duration: 500, curve: Curve.LinearOutSlowIn }, () => {​
  77. ​this.IndicatorLeftOffset = this.OtherWidth * index;​
  78. ​})​
  79. ​})​
  80. ​})​
  81. ​}​
  82. ​.width("100%")​
  83. ​.height(30)​
  84. ​.id("Tabs")​
  85. ​.onAreaChange((oldValue: Area, newValue: Area) => {​
  86. ​let tabWidth = newValue.width.valueOf() as number;​
  87. ​this.FirstWidth = 1.5 * tabWidth / (this.TabNames.length + 0.5);​
  88. ​this.OtherWidth = tabWidth / (this.TabNames.length + 0.5);​
  89. ​})​
  90. ​Swiper(this.SwiperController) {​
  91. ​ForEach(this.TabNames, (name: string, index: number) => {​
  92. ​Column() {​
  93. ​Text(`${name} - ${index}`)​
  94. ​.fontSize(24)​
  95. ​.fontWeight(FontWeight.Bold)​
  96. ​}​
  97. ​.alignItems(HorizontalAlign.Center)​
  98. ​.justifyContent(FlexAlign.Center)​
  99. ​.height("100%")​
  100. ​.width("100%")​
  101. ​})​
  102. ​}​
  103. ​.onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {​
  104. ​if (targetIndex > index) {​
  105. ​this.IndicatorLeftOffset += this.OtherWidth;​
  106. ​} else if (targetIndex < index) {​
  107. ​this.IndicatorLeftOffset -= this.OtherWidth;​
  108. ​}​
  109. ​this.IndicatorOffset = 0​
  110. ​this.SelectedTabIndex = targetIndex​
  111. ​})​
  112. ​.onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {​
  113. ​this.IndicatorOffset = 0​
  114. ​})​
  115. ​.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {​
  116. ​let move: number = this.GetOffset(extraInfo.currentOffset);​
  117. ​//这里需要限制边缘情况​
  118. ​if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||​
  119. ​(this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {​
  120. ​return;​
  121. ​}​
  122. ​this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;​
  123. ​})​
  124. ​.onAreaChange((oldValue: Area, newValue: Area) => {​
  125. ​let width = newValue.width.valueOf() as number;​
  126. ​this.SwiperWidth = width;​
  127. ​})​
  128. ​.curve(Curve.LinearOutSlowIn)​
  129. ​.loop(false)​
  130. ​.indicator(false)​
  131. ​.width("100%")​
  132. ​.id("MainContext")​
  133. ​.alignRules({​
  134. ​top: { anchor: "Tabs", align: VerticalAlign.Bottom },​
  135. ​bottom: { anchor: "__container__", align: VerticalAlign.Bottom }​
  136. ​})​
  137. ​}​
  138. ​.height('100%')​
  139. ​.width('100%')​
  140. ​.padding(10)​
  141. ​}​
  142. ​/**​
  143. ​* 需要注意的点,当前方法仅计算偏移值,不带方向​
  144. ​* @param swiperOffset​
  145. ​* @returns​
  146. ​*/​
  147. ​GetOffset(swiperOffset: number): number {​
  148. ​let swiperMoveRatio: number = Math.abs(swiperOffset / this.SwiperWidth);​
  149. ​let tabMoveValue: number = swiperMoveRatio >= 1 ? this.OtherWidth : this.OtherWidth * swiperMoveRatio;​
  150. ​return tabMoveValue;​
  151. ​}​
  152. ​}​

总结

这里实现了新的Tab选项卡的定义。但是没有进行高度封装,想法是方便读者理解组件的使用逻辑,而不是直接提供给读者进行调用。希望这篇文章可以帮助到你~~


©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
收藏
回复
举报


回复
    相关推荐