📖 鸿蒙NEXT开发实战系列 | 第32篇 | 实战篇 🎯 适合人群:有ArkUI基础的开发者 ⏰ 阅读时间:约15分钟 | 💻 开发环境:DevEco Studio 5.0+


导航链接


📑 目录


一、前言:动画在App中的重要性

好的动画设计能让App体验提升一个档次。动画不仅仅是视觉上的装饰,它在用户体验中扮演着重要角色:

  1. 引导用户注意力:通过动画引导用户关注重要内容

  2. 提供操作反馈:让用户的操作得到即时响应

  3. 增强界面连贯性:让页面切换更加自然流畅

  4. 提升品牌感知:独特的动画风格可以增强品牌形象

鸿蒙ArkUI提供了强大的动画系统,支持多种动画类型,能够满足各种复杂的动画需求。


二、动画类型总览

鸿蒙ArkUI的动画系统主要包含以下四种类型:

动画类型

使用场景

核心API

属性动画

组件属性变化时的过渡动画

animateTo()

显式动画

显式控制动画的开始和结束

animation()

转场动画

页面或组件的入场/退场动画

pageTransition()

路径动画

沿指定路径运动的动画

motionPath()


三、属性动画 animateTo

属性动画是鸿蒙中最常用的动画类型,当组件的属性发生变化时,系统会自动创建平滑的过渡动画。

3.1 基本语法

animateTo({
  duration: 300,                    // 动画时长(毫秒)
  tempo: 1.0,                       // 动画速率
  curve: Curve.EaseInOut,           // 动画曲线
  delay: 0,                         // 延迟时间
  iterations: 1,                    // 迭代次数(-1为无限循环)
  playMode: PlayMode.Normal,        // 播放模式
  onFinish: () => {}                // 动画结束回调
}, () => {
  // 属性变化的闭包
  this.width = 200
  this.opacity = 0.5
})

3.2 完整示例:按钮点击动画

@Entry
@Component
struct AnimateToDemo {
  @State width: number = 100
  @State height: number = 100
  @State opacity: number = 1
  @State rotateAngle: number = 0
  @State scaleValue: number = 1
  @State bgColor: string = '#007DFF'

  build() {
    Column({ space: 20 }) {
      Text('属性动画 animateTo 演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 动画演示区域
      Row() {
        Column() {
          Image($r('app.media.icon'))
            .width(this.width)
            .height(this.height)
            .opacity(this.opacity)
            .rotate({ angle: this.rotateAngle })
            .scale({ x: this.scaleValue, y: this.scaleValue })
            .backgroundColor(this.bgColor)
            .borderRadius(12)
            .animation({
              duration: 500,
              curve: Curve.EaseInOut
            })
        }
        .width('100%')
        .height(200)
        .justifyContent(FlexAlign.Center)
        .backgroundColor('#F5F5F5')
        .borderRadius(16)
      }
      .padding(16)

      // 控制按钮
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
        Button('改变大小')
          .onClick(() => {
            animateTo({
              duration: 500,
              curve: Curve.EaseInOut
            }, () => {
              this.width = this.width === 100 ? 200 : 100
              this.height = this.height === 100 ? 200 : 100
            })
          })
          .margin(8)

        Button('改变透明度')
          .onClick(() => {
            animateTo({
              duration: 300,
              curve: Curve.Linear
            }, () => {
              this.opacity = this.opacity === 1 ? 0.3 : 1
            })
          })
          .margin(8)

        Button('旋转')
          .onClick(() => {
            animateTo({
              duration: 800,
              curve: Curve.EaseOut
            }, () => {
              this.rotateAngle += 90
            })
          })
          .margin(8)

        Button('缩放')
          .onClick(() => {
            animateTo({
              duration: 400,
              curve: Curve.FastOutSlowIn
            }, () => {
              this.scaleValue = this.scaleValue === 1 ? 1.5 : 1
            })
          })
          .margin(8)

        Button('改变颜色')
          .onClick(() => {
            animateTo({
              duration: 600,
              curve: Curve.Linear
            }, () => {
              this.bgColor = this.bgColor === '#007DFF' ? '#FF6B6B' : '#007DFF'
            })
          })
          .margin(8)
      }
      .width('100%')
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

3.3 常用动画曲线

曲线类型

说明

适用场景

Curve.Linear

匀速运动

进度条、旋转

Curve.Ease

缓入缓出

通用动画

Curve.EaseIn

缓入

元素消失

Curve.EaseOut

缓出

元素出现

Curve.EaseInOut

缓入缓出

平滑过渡

Curve.FastOutSlowIn

快出慢入

Material Design风格

Curve.Spring

弹簧效果

回弹动画


四、显式动画

显式动画使用 animation() 属性修饰器,可以在属性变化时自动创建动画效果。与 animateTo 不同的是,显式动画可以为每个组件单独配置动画参数。

4.1 基本语法

Component()
  .width(this.width)
  .height(this.height)
  .animation({
    duration: 300,
    curve: Curve.EaseInOut,
    delay: 0,
    iterations: 1,
    playMode: PlayMode.Normal
  })

4.2 完整示例:列表项动画

@Entry
@Component
struct AnimationDemo {
  @State items: number[] = [1, 2, 3, 4, 5]
  @State showDetail: boolean = false
  @State rotateAngle: number = 0

  build() {
    Column({ space: 16 }) {
      Text('显式动画演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 展开/收起动画
      Column() {
        Row() {
          Text('点击展开详情')
            .fontSize(16)
            .layoutWeight(1)

          Image($r('app.media.arrow'))
            .width(24)
            .height(24)
            .rotate({ angle: this.rotateAngle })
            .animation({
              duration: 300,
              curve: Curve.EaseInOut
            })
        }
        .width('100%')
        .padding(16)
        .onClick(() => {
          animateTo({
            duration: 300,
            curve: Curve.EaseInOut
          }, () => {
            this.showDetail = !this.showDetail
            this.rotateAngle = this.showDetail ? 180 : 0
          })
        })

        if (this.showDetail) {
          Column({ space: 8 }) {
            Text('这里是详细内容区域')
            Text('可以放置更多信息...')
          }
          .width('100%')
          .padding(16)
          .opacity(this.showDetail ? 1 : 0)
          .animation({
            duration: 300,
            curve: Curve.EaseIn
          })
        }
      }
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .shadow({ radius: 4, color: '#1A000000' })

      // 列表项动画
      Column({ space: 12 }) {
        Text('列表项入场动画')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)

        ForEach(this.items, (item: number, index: number) => {
          Row() {
            Text(`Item ${item}`)
              .fontSize(16)
              .layoutWeight(1)
          }
          .width('100%')
          .height(50)
          .padding({ left: 16, right: 16 })
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
          .translate({ x: 0, y: 0 })
          .opacity(1)
          .animation({
            duration: 300,
            delay: index * 100,  // 延迟动画,形成波浪效果
            curve: Curve.EaseOut
          })
        }, (item: number) => item.toString())
      }

      Button('刷新列表')
        .onClick(() => {
          animateTo({
            duration: 500,
            curve: Curve.EaseInOut
          }, () => {
            // 触发列表重新渲染
            this.items = [...this.items]
          })
        })
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

4.3 组合动画示例

@Entry
@Component
struct CombinedAnimationDemo {
  @State positionX: number = 0
  @State positionY: number = 0
  @State scale: number = 1
  @State rotation: number = 0
  @State isAnimating: boolean = false

  build() {
    Column({ space: 20 }) {
      // 动画演示区域
      Stack() {
        Circle()
          .width(60)
          .height(60)
          .fill('#007DFF')
          .translate({ x: this.positionX, y: this.positionY })
          .scale({ x: this.scale, y: this.scale })
          .rotate({ angle: this.rotation })
          .animation({
            duration: 1000,
            curve: Curve.EaseInOut
          })
      }
      .width('100%')
      .height(200)
      .backgroundColor('#F0F0F0')
      .borderRadius(16)

      // 控制按钮
      Row({ space: 12 }) {
        Button(this.isAnimating ? '停止' : '开始')
          .onClick(() => {
            this.isAnimating = !this.isAnimating
            if (this.isAnimating) {
              this.startAnimation()
            }
          })

        Button('重置')
          .onClick(() => {
            this.isAnimating = false
            animateTo({
              duration: 500,
              curve: Curve.EaseOut
            }, () => {
              this.positionX = 0
              this.positionY = 0
              this.scale = 1
              this.rotation = 0
            })
          })
      }
    }
    .padding(16)
  }

  private startAnimation() {
    animateTo({
      duration: 2000,
      curve: Curve.EaseInOut,
      iterations: -1,  // 无限循环
      playMode: PlayMode.Alternate,
      onFinish: () => {
        if (this.isAnimating) {
          this.startAnimation()
        }
      }
    }, () => {
      this.positionX = 100
      this.positionY = 50
      this.scale = 1.5
      this.rotation = 360
    })
  }
}

五、转场动画

转场动画用于页面切换或组件的显示/隐藏过程中的过渡效果。

5.1 页面转场动画

@Entry
@Component
struct PageTransitionDemo {
  build() {
    Column() {
      Text('页面转场动画演示')
        .fontSize(24)

      Button('跳转到详情页')
        .onClick(() => {
          router.pushUrl({
            url: 'pages/DetailPage'
          })
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)

    // 页面入场转场动画
    .pageTransitionEnter({
      type: TransitionType.Push,
      duration: 500,
      curve: Curve.EaseInOut
    })

    // 页面退场转场动画
    .pageTransitionExit({
      type: TransitionType.Pop,
      duration: 300,
      curve: Curve.EaseOut
    })
  }
}

5.2 组件转场动画

@Entry
@Component
struct ComponentTransitionDemo {
  @State show: boolean = false

  build() {
    Column({ space: 20 }) {
      Text('组件转场动画演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      Button(this.show ? '隐藏组件' : '显示组件')
        .onClick(() => {
          this.show = !this.show
        })

      if (this.show) {
        Column() {
          Text('这是一个带转场动画的组件')
            .fontSize(16)
        }
        .width('80%')
        .height(150)
        .backgroundColor('#007DFF')
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)

        // 组件入场转场动画
        .transition({
          type: TransitionType.Insert,
          opacity: 0,
          translate: { x: 0, y: -50 },
          scale: { x: 0.8, y: 0.8 }
        })

        // 组件退场转场动画
        .transition({
          type: TransitionType.Delete,
          opacity: 0,
          translate: { x: 0, y: 50 },
          scale: { x: 0.8, y: 0.8 }
        })
      }
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

5.3 自定义转场动画

@Entry
@Component
struct CustomTransitionDemo {
  @State currentIndex: number = 0
  private items: string[] = ['页面1', '页面2', '页面3']

  build() {
    Column({ space: 20 }) {
      // 导航按钮
      Row({ space: 20 }) {
        Button('上一页')
          .onClick(() => {
            if (this.currentIndex > 0) {
              this.currentIndex--
            }
          })

        Text(`${this.currentIndex + 1} / ${this.items.length}`)
          .fontSize(18)

        Button('下一页')
          .onClick(() => {
            if (this.currentIndex < this.items.length - 1) {
              this.currentIndex++
            }
          })
      }

      // 内容区域 - 使用自定义转场
      Stack() {
        ForEach(this.items, (item: string, index: number) => {
          if (index === this.currentIndex) {
            Column() {
              Text(item)
                .fontSize(24)
                .fontColor('#FFFFFF')
            }
            .width('100%')
            .height(200)
            .backgroundColor(this.getColor(index))
            .borderRadius(16)
            .justifyContent(FlexAlign.Center)

            // 自定义入场动画
            .transition({
              type: TransitionType.Insert,
              opacity: 0,
              translate: { x: 300 },
              curve: Curve.EaseOut
            })

            // 自定义退场动画
            .transition({
              type: TransitionType.Delete,
              opacity: 0,
              translate: { x: -300 },
              curve: Curve.EaseIn
            })
          }
        }, (item: string) => item)
      }
      .width('100%')
      .clip(true)
    }
    .padding(16)
  }

  private getColor(index: number): ResourceColor {
    const colors: ResourceColor[] = ['#007DFF', '#FF6B6B', '#4CAF50']
    return colors[index % colors.length]
  }
}

六、路径动画

路径动画可以让组件沿着指定的路径运动,常用于制作复杂的动画效果。

6.1 基本语法

Component()
  .motionPath({
    path: 'MstartX startY LendX endY',  // SVG路径
    from: 0,                              // 起始位置(0-1)
    to: 1,                                // 结束位置(0-1)
    rotatable: true                       // 是否跟随路径旋转
  })

6.2 完整示例:圆形路径动画

@Entry
@Component
struct MotionPathDemo {
  @State progress: number = 0
  @State isRunning: boolean = false
  private timer: number = 0

  build() {
    Column({ space: 20 }) {
      Text('路径动画演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      // 圆形路径动画
      Stack({ alignContent: Alignment.Center }) {
        // 圆形轨道
        Circle()
          .width(200)
          .height(200)
          .stroke('#E0E0E0')
          .strokeWidth(2)
          .fill('transparent')

        // 运动的小球
        Circle()
          .width(30)
          .height(30)
          .fill('#007DFF')
          .motionPath({
            path: 'M100,0 A100,100 0 1,1 100,200 A100,100 0 1,1 100,0',
            from: 0,
            to: 1,
            rotatable: true
          })
          .animation({
            duration: 3000,
            iterations: -1,
            curve: Curve.Linear
          })
      }
      .width(250)
      .height(250)

      // 贝塞尔曲线路径动画
      Stack() {
        // 路径轨迹(可视化)
        Path()
          .width('100%')
          .height(150)
          .commands('M50,75 C150,0 250,150 350,75')
          .stroke('#E0E0E0')
          .strokeWidth(2)
          .fill('transparent')

        // 运动的元素
        Circle()
          .width(20)
          .height(20)
          .fill('#FF6B6B')
          .motionPath({
            path: 'M50,75 C150,0 250,150 350,75',
            from: 0,
            to: 1,
            rotatable: false
          })
          .animation({
            duration: 2000,
            iterations: -1,
            playMode: PlayMode.Alternate,
            curve: Curve.EaseInOut
          })
      }
      .width('100%')
      .height(150)

      // 自定义路径动画
      Column() {
        Text('自定义路径动画')
          .fontSize(16)
          .margin({ bottom: 10 })

        Stack() {
          // 路径轨迹
          Path()
            .width('100%')
            .height(100)
            .commands('M20,50 Q100,0 180,50 Q260,100 340,50')
            .stroke('#CCCCCC')
            .strokeWidth(1)
            .strokeDashArray([5, 5])
            .fill('transparent')

          // 运动的飞机图标
          Text('✈️')
            .fontSize(24)
            .motionPath({
              path: 'M20,50 Q100,0 180,50 Q260,100 340,50',
              from: 0,
              to: 1,
              rotatable: true
            })
            .animation({
              duration: 3000,
              iterations: -1,
              curve: Curve.Linear
            })
        }
        .width('100%')
        .height(100)
      }
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }
}

6.3 多路径动画组合

@Entry
@Component
struct MultiPathDemo {
  @State isAnimating: boolean = false

  build() {
    Column({ space: 30 }) {
      Text('多路径动画组合')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)

      Stack() {
        // 多个小球沿不同路径运动
        ForEach([0, 1, 2], (index: number) => {
          Circle()
            .width(20)
            .height(20)
            .fill(this.getBallColor(index))
            .motionPath({
              path: this.getPath(index),
              from: 0,
              to: 1,
              rotatable: false
            })
            .animation({
              duration: 2000 + index * 500,
              iterations: -1,
              playMode: PlayMode.Alternate,
              curve: Curve.EaseInOut
            })
        }, (index: number) => index.toString())
      }
      .width('100%')
      .height(200)
      .backgroundColor('#F5F5F5')
      .borderRadius(16)

      Button(this.isAnimating ? '停止动画' : '开始动画')
        .onClick(() => {
          this.isAnimating = !this.isAnimating
        })
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }

  private getPath(index: number): string {
    const paths: string[] = [
      'M30,100 C100,20 200,180 270,100',
      'M30,100 C100,180 200,20 270,100',
      'M30,100 Q150,30 270,100'
    ]
    return paths[index]
  }

  private getBallColor(index: number): ResourceColor {
    const colors: ResourceColor[] = ['#007DFF', '#FF6B6B', '#4CAF50']
    return colors[index]
  }
}

七、动画性能优化

在开发动画时,性能优化是非常重要的。以下是一些最佳实践:

7.1 避免过度动画

// ❌ 错误示例:同时执行过多动画
animateTo({ duration: 300 }, () => {
  this.width = 200
  this.height = 200
  this.opacity = 0.5
  this.translate = { x: 100, y: 100 }
  this.rotate = 45
  this.scale = { x: 1.5, y: 1.5 }
  this.backgroundColor = '#FF0000'
  // ... 更多属性变化
})

// ✅ 正确示例:精简动画属性
animateTo({ duration: 300 }, () => {
  this.width = 200
  this.height = 200
})

7.2 使用硬件加速属性

// ✅ 推荐:使用这些属性可以获得硬件加速
Component()
  .translate({ x: 100, y: 100 })  // 位移
  .scale({ x: 1.5, y: 1.5 })      // 缩放
  .rotate({ angle: 45 })           // 旋转
  .opacity(0.5)                    // 透明度

// ⚠️ 谨慎使用:这些属性动画性能开销较大
Component()
  .width(200)    // 尺寸变化可能触发重绘
  .height(200)
  .backgroundColor('#FF0000')  // 背景色变化

7.3 合理设置动画参数

// ✅ 合理的动画时长
animateTo({
  duration: 300,           // 推荐300ms左右
  curve: Curve.EaseInOut,  // 使用合适的曲线
}, () => {
  // 属性变化
})

// ❌ 不推荐:动画时间过长
animateTo({
  duration: 3000,  // 太长的动画会让用户感到厌烦
}, () => {
  // 属性变化
})

7.4 避免动画冲突

// ❌ 错误示例:多个动画同时作用于同一组件
Button('动画')
  .onClick(() => {
    animateTo({ duration: 300 }, () => {
      this.width = 200
    })
    animateTo({ duration: 500 }, () => {
      this.width = 300  // 与上面的动画冲突
    })
  })

// ✅ 正确示例:使用单一动画控制
Button('动画')
  .onClick(() => {
    animateTo({
      duration: 500,
      curve: Curve.EaseInOut
    }, () => {
      this.width = this.width === 100 ? 300 : 100
    })
  })

7.5 性能优化清单

优化项

建议

说明

动画时长

200-500ms

太短看不清,太长影响体验

帧率

60fps

保证动画流畅

动画属性

使用位移/缩放/旋转/透明度

这些属性可以硬件加速

避免重绘

减少width/height动画

尺寸变化会触发重绘

减少层数

避免过深的组件嵌套

减少渲染负担

7.6 动画调试技巧

// 开启动画调试
animateTo({
  duration: 1000,
  curve: Curve.EaseInOut,
  // 添加动画开始和结束回调,用于调试
  onFinish: () => {
    console.info('动画完成')
  }
}, () => {
  this.width = 200
})

八、总结与最佳实践

8.1 动画类型选择指南

场景

推荐动画类型

原因

属性值变化

animateTo

简单易用,自动插值

组件显示/隐藏

transition

专门用于转场场景

页面切换

pageTransition

提供完整的页面转场

复杂路径运动

motionPath

支持SVG路径描述

需要精确控制

animation

可单独配置每个组件

8.2 最佳实践总结

  1. 保持简洁:动画应该是微妙的,不要过度使用

  2. 保持一致:整个App的动画风格应该统一

  3. 性能优先:优先使用硬件加速属性

  4. 响应迅速:动画时长控制在300ms左右

  5. 有意义:动画应该服务于功能,而非纯粹装饰

8.3 常见问题解答

Q: 动画卡顿怎么办? A: 检查是否使用了重绘属性,尝试使用位移/缩放/旋转/透明度等硬件加速属性。

Q: 如何实现循环动画? A: 设置 iterations: -1 即可实现无限循环。

Q: 如何实现反向动画? A: 使用 playMode: PlayMode.Alternate 可以实现正向-反向交替播放。


九、系列文章推荐


标签:鸿蒙动画, ArkUI动画, animateTo, 转场动画, 路径动画, 显式动画, 动画性能优化

版权声明:本文为原创技术文章,转载请注明出处。

系列信息:鸿蒙NEXT开发实战系列 - 专注于实战的鸿蒙开发教程

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐