文档管理中心
您当前正在浏览新版开发者文档中心,目录分类和层级有所调整。点击左侧当前文档分类名称前的“☰”图标,可切换文档分类。 了解新版目录
问题处理与运维开发与测试问题应用框架开发UI框架UI界面如何设置TabBar切换动画

如何设置TabBar切换动画

问题现象

场景一:切换TabBar如何实现先放大后缩小复原动效?

场景二:如何实现点击空白区域实现Tab栏下滑隐藏动效?

场景三:切换TabBar如何实现背景跟随手指拖动效果?

背景知识

  • Tabs:进行内容视图切换的容器组件,每个页签对应一个内容视图。
  • tabBar:设置TabBar上显示内容。
  • barHeight:设置TabBar的高度值。
  • onAnimationEnd:切换动画结束时触发该回调,包括动画过程中手势中断。当animationDuration为0时动画关闭,不触发该回调。
  • onGestureSwipe:在页面跟手滑动过程中,逐帧触发该回调。
  • animateTo:用于为闭包代码中的状态变化添加过渡动画效果。
  • translate:设置组件平移。
  • visibility:控制组件的显示或隐藏。
  • onAreaChange:组件区域变化时触发该回调。仅会响应由布局变化所导致的组件大小、位置发生变化时的回调。

解决方案

展开

实现场景

实现方案

场景一:实现先放大后缩小复原动效。

使用Stack将自定义Tab栏与Tabs的TabBar区域重合,并通过 if/else 控制自定义Tab栏的渲染。通过animateTo的onFinish回调实现Tab内容的先放大后缩小,最后复原的动画效果。

场景二:点击空白区域实现Tab栏下滑隐藏动效。

使用animateTo和translate方法控制Tab栏内容在y轴上的下滑动画效果。通过onClick事件触发动画,实现Tab栏的隐藏和显示。

场景三:实现背景跟随手指拖动效果。

使用Stack将Tabs与背景组件堆叠,通过onAreaChange获取Tab宽度,计算中心位置。滑动时触发onGestureSwipe,背景跟随手指移动。滑动结束触发onAnimationEnd,根据移动距离判断是否吸附到目标Tab中心或回弹。

场景一:实现先放大后缩小复原动效

使用Stack将自定义Tab栏与Tabs的TabBar区域重合,并通过if/else对自定义Tab栏进行渲染控制。通过animateTo中onFinish回调实现Tab内容的先放大后缩小,最后复原的动画效果。参考代码如下所示:

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @Component
  3. struct OutFrame {
  4. fontColor: string = '#182431';
  5. selectedFontColor: string = '#007DFF';
  6. iconMinHeight: number = 640;
  7. // 图片资源仅供参考,开发者可替换成所需资源
  8. iconList: Resource[] = [
  9. $r('app.media.person'),
  10. $r('app.media.ellipsis_message'),
  11. $r('app.media.envelope'),
  12. $r('app.media.magnifyingglass')
  13. ];
  14. iconX: number = 20; // 图片位置x
  15. iconY: number = 65; // 图片位置Y
  16. private controller: TabsController = new TabsController();
  17. @State currentIndex: number = 0;
  18. @State iconWidth: number = 80; // 图片宽度
  19. @State iconHeight: number = 80; // 图片高度
  20. @State showAnimation: boolean = false; // 控制动画显示
  21. @State showTabIcon: boolean = true; // 控制Tab栏图标显隐
  22. @Builder
  23. tabBuilder(img: Resource, index: number) {
  24. Column() {
  25. Image(img)
  26. .width(30)
  27. .height(30)
  28. .visibility((this.showTabIcon && index === this.currentIndex) || index !== this.currentIndex ?
  29. Visibility.Visible : Visibility.Hidden);
  30. }
  31. .width('100%')
  32. .padding({ top: 5, bottom: 5 });
  33. }
  34. build() {
  35. Stack({ alignContent: Alignment.Bottom }) {
  36. if (this.showAnimation) {
  37. Row() {
  38. ForEach(this.iconList, (item: Resource, index: number) => {
  39. ListItem() {
  40. Image(item)
  41. .width(this.iconWidth)
  42. .height(this.iconHeight)
  43. .visibility(this.currentIndex === index ? Visibility.Visible : Visibility.Hidden);
  44. }
  45. .margin({ top: -20 });
  46. });
  47. }
  48. .width('90%')
  49. .justifyContent(FlexAlign.SpaceAround)
  50. .zIndex(1)
  51. .position({ x: this.iconX, y: this.iconY + this.iconMinHeight });
  52. }
  53. Tabs({ barPosition: BarPosition.End, controller: this.controller }) {
  54. ForEach(this.iconList, (item: Resource, index: number) => {
  55. TabContent() {
  56. Text(`页签${index + 1}的内容`)
  57. .fontSize(20)
  58. .fontColor('#000000');
  59. }
  60. .backgroundColor('#ffffff')
  61. .tabBar(this.tabBuilder(item, index));
  62. });
  63. }
  64. .zIndex(0)
  65. .vertical(false)
  66. .barMode(BarMode.Fixed)
  67. .barWidth(360)
  68. .barHeight('auto')
  69. .animationDuration(400)
  70. .onChange((index: number) => {
  71. // currentIndex控制TabContent显示页签
  72. this.currentIndex = index;
  73. this.showAnimation = true;
  74. this.showTabIcon = false;
  75. this.iconWidth = 70;
  76. this.iconHeight = 70;
  77. this.iconY = 65;
  78. // 设置动画实现效果
  79. this.getUIContext().animateTo({
  80. delay: 0,
  81. duration: 500,
  82. onFinish: () => {
  83. this.getUIContext().animateTo({
  84. delay: 0,
  85. duration: 500,
  86. onFinish: () => {
  87. this.showAnimation = false;
  88. this.showTabIcon = true;
  89. }
  90. }, () => {
  91. this.iconWidth = 5;
  92. this.iconHeight = 5;
  93. this.iconY = 80;
  94. });
  95. }
  96. }, () => {
  97. this.iconY = 45;
  98. });
  99. })
  100. .onAnimationStart((index: number, targetIndex: number) => {
  101. if (index === targetIndex) {
  102. return;
  103. }
  104. })
  105. .width('100%')
  106. .height('100%')
  107. .backgroundColor('#F1F3F5');
  108. }
  109. .width('100%');
  110. }
  111. }

效果图如下:

场景二:点击空白区域实现Tab栏下滑隐藏动效

使用animateTo和translate方法控制Tab栏内容在y轴上的下滑动画效果。参考代码如下所示:

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @Component
  3. struct HideTabBar {
  4. // 图片资源仅供参考,开发者可替换成所需资源
  5. iconList: Resource[] = [
  6. $r('app.media.person'),
  7. $r('app.media.ellipsis_message'),
  8. $r('app.media.envelope'),
  9. $r('app.media.magnifyingglass')
  10. ];
  11. barHeight: number = 70;
  12. @State transY: number = 0;
  13. @State showTabBar: boolean = true;
  14. @Builder
  15. tabBuilder(img: Resource) {
  16. Column() {
  17. Image(img)
  18. .width(30)
  19. .height(30);
  20. }
  21. .width('100%')
  22. .padding({ top: 5, bottom: 5 })
  23. .translate({ y: this.transY });
  24. }
  25. build() {
  26. Tabs({ barPosition: BarPosition.End }) {
  27. ForEach(this.iconList, (item: Resource, index: number) => {
  28. TabContent() {
  29. Text(`页签${index + 1}的内容`)
  30. .fontSize(20);
  31. }
  32. .tabBar(this.tabBuilder(item))
  33. .backgroundColor('#ffffff');
  34. });
  35. }
  36. .barHeight(this.barHeight)
  37. .vertical(false)
  38. .height('100%')
  39. .width('100%')
  40. .onClick(() => {
  41. // 添加消失动画
  42. this.getUIContext().animateTo({
  43. delay: 0,
  44. duration: 500,
  45. curve: Curve.EaseOut,
  46. iterations: 1
  47. }, () => {
  48. if (this.showTabBar) {
  49. this.transY = this.barHeight;
  50. } else {
  51. this.transY = 0;
  52. }
  53. this.showTabBar = !this.showTabBar;
  54. });
  55. });
  56. }
  57. }

效果图如下:

场景三:实现背景跟随手指拖动效果

  1. 使用Stack将Tabs与背景组件堆叠,并通过onAreaChange获取页面和单个Tab的宽度,计算每个Tab的中心位置。
  2. 滑动过程中触发onGestureSwipe,根据页面滑动的比例让背景跟随手指移动。
  3. 页面滑动结束触发onAnimationEnd,根据背景移动距离占总切换距离的比例判断是否超过设定阈值,超过则吸附到目标Tab的中心,未超过则回弹至原Tab的中心。

参考代码如下所示:

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @Component
  3. struct DragAction {
  4. // 图片资源仅供参考,开发者可替换成所需资源
  5. iconList: Resource[] = [
  6. $r('app.media.person'),
  7. $r('app.media.ellipsis_message'),
  8. $r('app.media.envelope'),
  9. $r('app.media.magnifyingglass')
  10. ];
  11. private controller: TabsController = new TabsController();
  12. threshold: number = 0.7; // 吸附阈值
  13. barHeight: number = 56;
  14. circleHeight: number = 50;
  15. circleWidth: number = 50;
  16. @State barWidth: number = 0; // 单个TabBar的宽度值
  17. @State currentIndex: number = 0;
  18. @State bgTransX: number = 0; // 背景的X偏移量
  19. @State pageWidth: number = 0; // tabContent内页面的宽度
  20. @Builder
  21. tabBuilder(img: Resource) {
  22. Column() {
  23. Image(img)
  24. .width(30)
  25. .height(30);
  26. }
  27. .width('100%');
  28. }
  29. // 计算背景的X偏移量
  30. getBgOffset(index: number) {
  31. return index * this.barWidth + (this.barWidth - this.circleWidth) / 2;
  32. }
  33. build() {
  34. Stack({ alignContent: Alignment.BottomStart }) {
  35. Circle()
  36. .width(this.circleWidth)
  37. .height(this.circleHeight)
  38. .zIndex(3)
  39. .fillOpacity(0.05)
  40. .animation({ duration: 200, curve: Curve.EaseOut })
  41. .translate({
  42. x: this.bgTransX,
  43. y: -(this.barHeight - this.circleHeight) / 2
  44. });
  45. Tabs({ barPosition: BarPosition.End, controller: this.controller }) {
  46. ForEach(this.iconList, (item: Resource, index: number) => {
  47. TabContent() {
  48. Text(`页签${index + 1}的内容`)
  49. .fontSize(20);
  50. }
  51. .tabBar(this.tabBuilder(item))
  52. .backgroundColor('#ffffff');
  53. });
  54. }
  55. // 获取单个TabBar的宽度值、背景的X偏移量、tabContent内页面的宽度
  56. .onAreaChange((old, current) => {
  57. console.info(`Succeeded in getting info.Old:${old.width},current:${current.width}.`);
  58. this.barWidth = Number(current.width) / this.iconList.length;
  59. this.bgTransX = this.getBgOffset(0);
  60. this.pageWidth = Number(current.width);
  61. })
  62. .id('tabs')
  63. .zIndex(1)
  64. .barMode(BarMode.Fixed)
  65. .scrollable(true)
  66. .animationDuration(200)
  67. .onChange((index: number) => {
  68. this.getUIContext().animateTo({
  69. delay: 0,
  70. duration: 200,
  71. curve: Curve.EaseOut,
  72. iterations: 1
  73. }, () => {
  74. this.currentIndex = index;
  75. // 重新偏移
  76. this.bgTransX = this.getBgOffset(index);
  77. this.currentIndex = index;
  78. });
  79. })
  80. .vertical(false)
  81. .height('100%')
  82. .width('100%')
  83. .backgroundColor('#ffffff')
  84. // 滑动过程中:跟随手指滑动
  85. .onGestureSwipe((index: number, event: TabsAnimationEvent) => {
  86. if (this.pageWidth <= 0 || this.barWidth <= 0) {
  87. return;
  88. }
  89. const ratio = event.currentOffset / this.pageWidth;
  90. const from = this.getBgOffset(index);
  91. this.bgTransX = from - ratio * this.barWidth;
  92. })
  93. // 松手后
  94. .onAnimationEnd((index: number) => {
  95. const targetIndex = index + 1;
  96. const from = this.getBgOffset(index); // 当前Tab的中心位置
  97. const to = this.getBgOffset(targetIndex); // 目标Tab的中心位置
  98. const totalMove = to - from;
  99. const currentMove = this.bgTransX - from;
  100. const progress = Math.abs(currentMove / totalMove); // 滑动进度
  101. this.getUIContext().animateTo({
  102. delay: 0,
  103. duration: 200,
  104. curve: Curve.EaseOut,
  105. iterations: 1
  106. }, () => {
  107. // 超过阈值,吸附到目标图标中心
  108. if (progress >= this.threshold) {
  109. this.bgTransX = to;
  110. } else {
  111. // 回弹原位置
  112. this.bgTransX = from;
  113. }
  114. });
  115. });
  116. }
  117. .height('100%')
  118. .width('100%');
  119. }
  120. }

效果图如下:

常见FAQ

Q:为什么对SVG和PNG使用animateTo,最终动画效果不同?

A:SVG的矢量特性支持属性级动画,PNG作为栅格图像,仅能通过组件的通用动画属性。设置SVG图片的动画,开发者可参考svg动画。

Q:如何优化动画效果?

A:优化动画效果可通过减少动画的复杂性、优化代码实现、使用硬件加速、调整动画帧率、缓存和重用动画资源等方式实现。