本篇学习属性动画、转场动画、手势识别,实现祝福卡片弹出动画

图:古今职鉴开源教程封面。本篇围绕「动画与交互:让界面"活"起来」展开。

学习目标

完成本篇后,你将能够:

  • ✅ 使用 animateTo 实现属性动画
  • ✅ 使用 transition 实现转场动画
  • ✅ 掌握常用手势识别
  • ✅ 实现祝福卡片弹出动画效果

预计学习时间

约 90 分钟

---

实战一:animateTo 属性动画

第一步:创建 lesson08 目录和文件

products/jiaocheng/src/main/ets/ 下创建 lesson08 文件夹,新建 Lesson08Page.ets

// 文件路径:products/jiaocheng/src/main/ets/lesson08/Lesson08Page.ets

@Entry
@Component
struct Lesson08Page {
  @State boxScale: number = 1;

  build() {
    Column({ space: 20 }) {
      Text('animateTo 动画演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')

      // 动画目标元素
      Column()
        .width(100)
        .height(100)
        .backgroundColor('#c41e3a')
        .borderRadius(12)
        .scale({ x: this.boxScale, y: this.boxScale })

      Button('点击放大/缩小')
        .onClick(() => {
          // animateTo 包裹状态变化
          animateTo({
            duration: 300,        // 动画时长(毫秒)
            curve: Curve.EaseOut  // 动画曲线
          }, () => {
            // 状态变化
            this.boxScale = this.boxScale === 1 ? 1.5 : 1;
          });
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#f8f6f5')
  }
}

第二步:理解 animateTo 的工作原理

animateTo({
  duration: 300,           // 动画时长
  curve: Curve.EaseOut,    // 动画曲线
  delay: 0,                // 延迟开始
  onFinish: () => {}       // 完成回调
}, () => {
  // 在这里修改状态
  // 状态变化会以动画形式呈现
  this.boxScale = 1.5;
});

关键点

  • animateTo 不是让某个属性动画,而是让状态变化产生动画
  • 状态变化写在第二个回调函数里
  • 所有依赖该状态的 UI 都会动画更新

第三步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 点击按钮,红色方块平滑放大到 1.5 倍
  • 再次点击,平滑缩小回原始大小

---

实战二:多属性同时动画

第一步:添加多个动画状态

修改组件,添加更多状态:

@Entry
@Component
struct Lesson08Page {
  @State boxScale: number = 1;
  @State boxOpacity: number = 1;
  @State boxRotate: number = 0;

  build() {
    Column({ space: 20 }) {
      Text('多属性动画')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')

      Column()
        .width(100)
        .height(100)
        .backgroundColor('#c41e3a')
        .borderRadius(12)
        .scale({ x: this.boxScale, y: this.boxScale })
        .opacity(this.boxOpacity)
        .rotate({ angle: this.boxRotate })

      Button('组合动画')
        .onClick(() => {
          animateTo({
            duration: 500,
            curve: Curve.Spring  // 弹簧效果
          }, () => {
            // 同时改变多个属性
            if (this.boxScale === 1) {
              this.boxScale = 1.3;
              this.boxOpacity = 0.7;
              this.boxRotate = 15;
            } else {
              this.boxScale = 1;
              this.boxOpacity = 1;
              this.boxRotate = 0;
            }
          });
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#f8f6f5')
  }
}

第二步:理解动画曲线

曲线 效果 适用场景
Curve.Linear 匀速 进度条
Curve.EaseIn 开始慢,结束快 退出动画
Curve.EaseOut 开始快,结束慢 进入动画
Curve.EaseInOut 两端慢,中间快 通用过渡
Curve.Spring 弹簧效果 弹出、强调

第三步:运行验证

hvigorw assembleHap --no-daemon

预期效果

  • 点击按钮,方块同时放大、变透明、旋转
  • 动画有弹簧回弹效果

---

实战三:转场动画 transition

第一步:理解 transition 的作用

transition 用于组件出现/消失时的动画,配合 if 条件渲染使用。

第二步:实现显示/隐藏动画

@Entry
@Component
struct Lesson08Page {
  @State isVisible: boolean = false;

  build() {
    Column({ space: 20 }) {
      Text('转场动画演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')

      Button(this.isVisible ? '隐藏卡片' : '显示卡片')
        .onClick(() => {
          // 必须用 animateTo 包裹
          animateTo({ duration: 300 }, () => {
            this.isVisible = !this.isVisible;
          });
        })

      if (this.isVisible) {
        Column() {
          Text('我是卡片')
            .fontSize(18)
            .fontColor(Color.White)
        }
        .width(200)
        .height(120)
        .backgroundColor('#c41e3a')
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)
        // 转场动画
        .transition(
          TransitionEffect.OPACITY
            .combine(TransitionEffect.scale({ x: 0.8, y: 0.8 }))
        )
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#f8f6f5')
  }
}

第三步:理解 TransitionEffect

// 单一效果
TransitionEffect.OPACITY              // 透明度
TransitionEffect.scale({ x: 0, y: 0 }) // 缩放
TransitionEffect.translate({ y: 100 }) // 平移
TransitionEffect.rotate({ angle: 90 }) // 旋转

// 组合效果
TransitionEffect.OPACITY
  .combine(TransitionEffect.scale({ x: 0.5, y: 0.5 }))
  .combine(TransitionEffect.translate({ y: 50 }))

第四步:运行验证

hvigorw assembleHap --no-daemon

预期效果

  • 点击按钮,卡片淡入并放大出现
  • 再次点击,卡片淡出并缩小消失

---

实战四:手势识别

第一步:点击手势 TapGesture

@Entry
@Component
struct Lesson08Page {
  @State tapCount: number = 0;

  build() {
    Column({ space: 20 }) {
      Text('手势识别演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')

      Text(`点击次数: ${this.tapCount}`)
        .fontSize(16)
        .fontColor('#64748b')

      // 单击
      Column() {
        Text('单击我')
          .fontSize(16)
          .fontColor(Color.White)
      }
      .width(120)
      .height(60)
      .backgroundColor('#c41e3a')
      .borderRadius(8)
      .justifyContent(FlexAlign.Center)
      .gesture(
        TapGesture({ count: 1 })
          .onAction(() => {
            this.tapCount++;
          })
      )

      // 双击
      Column() {
        Text('双击我')
          .fontSize(16)
          .fontColor(Color.White)
      }
      .width(120)
      .height(60)
      .backgroundColor('#4169e1')
      .borderRadius(8)
      .justifyContent(FlexAlign.Center)
      .gesture(
        TapGesture({ count: 2 })
          .onAction(() => {
            this.tapCount += 10;
          })
      )
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#f8f6f5')
  }
}

第二步:长按手势 LongPressGesture

Column() {
  Text('长按我')
    .fontSize(16)
    .fontColor(Color.White)
}
.width(120)
.height(60)
.backgroundColor('#228b22')
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.gesture(
  LongPressGesture({ duration: 500 })  // 500毫秒触发
    .onAction(() => {
      console.log('长按触发');
    })
)

第三步:拖动手势 PanGesture

@State offsetX: number = 0;
@State offsetY: number = 0;

Column() {
  Text('拖动我')
    .fontSize(16)
    .fontColor(Color.White)
}
.width(100)
.height(100)
.backgroundColor('#c41e3a')
.borderRadius(12)
.justifyContent(FlexAlign.Center)
.translate({ x: this.offsetX, y: this.offsetY })
.gesture(
  PanGesture()
    .onActionUpdate((event: GestureEvent) => {
      this.offsetX = event.offsetX;
      this.offsetY = event.offsetY;
    })
    .onActionEnd(() => {
      // 松手回到原位
      animateTo({ duration: 300 }, () => {
        this.offsetX = 0;
        this.offsetY = 0;
      });
    })
)

第四步:运行验证

hvigorw assembleHap --no-daemon

---

实战五:综合实战 - 祝福卡片弹出动画

第一步:分析动画需求

实现效果:

  1. 点击按钮,背景遮罩渐显
  2. 卡片从底部弹出,带缩放和透明度动画
  3. 点击遮罩或关闭按钮,卡片收起

第二步:定义动画状态

@Entry
@Component
struct Lesson08Page {
  // 控制卡片显示
  @State isCardVisible: boolean = false;
  // 卡片动画属性
  @State cardScale: number = 0.8;
  @State cardOpacity: number = 0;
  @State cardOffsetY: number = 100;
  // 遮罩透明度
  @State maskOpacity: number = 0;

  // ... build 方法
}

第三步:实现页面结构

build() {
  Stack() {
    // 主页面
    Column() {
      Text('祝福卡片动画')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')
        .margin({ top: 100 })

      Blank()

      Button('抽取祝福')
        .width('80%')
        .height(50)
        .backgroundColor('#c41e3a')
        .margin({ bottom: 50 })
        .onClick(() => {
          this.showCard();
        })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f8f6f5')

    // 遮罩层
    if (this.isCardVisible) {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(this.maskOpacity)
        .onClick(() => {
          this.hideCard();
        })
    }

    // 祝福卡片
    if (this.isCardVisible) {
      this.BlessingCard()
    }
  }
  .width('100%')
  .height('100%')
}

第四步:实现卡片 Builder

@Builder
BlessingCard() {
  Column() {
    // 关闭按钮
    Image($r('app.media.ic_close'))
      .width(24)
      .height(24)
      .fillColor('#64748b')
      .position({ x: '85%', y: 16 })
      .onClick(() => {
        this.hideCard();
      })

    // 卡片内容
    Column() {
      Text('🎊')
        .fontSize(48)

      Text('新年快乐')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#c41e3a')
        .margin({ top: 20 })

      Text('龙马精神')
        .fontSize(20)
        .fontColor('#1e293b')
        .margin({ top: 8 })

      Text('愿你在新的一年里')
        .fontSize(14)
        .fontColor('#64748b')
        .margin({ top: 24 })

      Text('事业有成,阖家幸福')
        .fontSize(14)
        .fontColor('#64748b')
        .margin({ top: 4 })
    }
    .margin({ top: 40 })
  }
  .width(280)
  .height(380)
  .backgroundColor(Color.White)
  .borderRadius(20)
  .shadow({
    radius: 20,
    color: 'rgba(0, 0, 0, 0.2)',
    offsetY: 10
  })
  .scale({ x: this.cardScale, y: this.cardScale })
  .opacity(this.cardOpacity)
  .translate({ y: this.cardOffsetY })
}

第五步:实现显示/隐藏方法

// 显示卡片
showCard() {
  this.isCardVisible = true;

  animateTo({
    duration: 400,
    curve: Curve.Spring
  }, () => {
    this.cardScale = 1;
    this.cardOpacity = 1;
    this.cardOffsetY = 0;
    this.maskOpacity = 0.5;
  });
}

// 隐藏卡片
hideCard() {
  animateTo({
    duration: 250,
    curve: Curve.EaseIn,
    onFinish: () => {
      this.isCardVisible = false;
      // 重置状态
      this.cardScale = 0.8;
      this.cardOpacity = 0;
      this.cardOffsetY = 100;
      this.maskOpacity = 0;
    }
  }, () => {
    this.cardScale = 0.8;
    this.cardOpacity = 0;
    this.cardOffsetY = 100;
    this.maskOpacity = 0;
  });
}

第六步:运行验证

hvigorw assembleHap --no-daemon

预期效果

  • 点击按钮,遮罩渐显,卡片弹出
  • 卡片有缩放、透明度、位移动画
  • 点击遮罩或关闭按钮,卡片收起
  • 动画流畅自然

---

完整代码

// 文件路径:products/jiaocheng/src/main/ets/lesson08/Lesson08Page.ets

@Entry
@Component
struct Lesson08Page {
  @State isCardVisible: boolean = false;
  @State cardScale: number = 0.8;
  @State cardOpacity: number = 0;
  @State cardOffsetY: number = 100;
  @State maskOpacity: number = 0;

  build() {
    Stack() {
      Column() {
        Text('祝福卡片动画')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1e293b')
          .margin({ top: 100 })

        Blank()

        Button('抽取祝福')
          .width('80%')
          .height(50)
          .backgroundColor('#c41e3a')
          .margin({ bottom: 50 })
          .onClick(() => {
            this.showCard();
          })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#f8f6f5')

      if (this.isCardVisible) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('#000000')
          .opacity(this.maskOpacity)
          .onClick(() => {
            this.hideCard();
          })
      }

      if (this.isCardVisible) {
        this.BlessingCard()
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  BlessingCard() {
    Column() {
      Image($r('app.media.ic_close'))
        .width(24)
        .height(24)
        .fillColor('#64748b')
        .position({ x: '85%', y: 16 })
        .onClick(() => {
          this.hideCard();
        })

      Column() {
        Text('🎊')
          .fontSize(48)

        Text('新年快乐')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor('#c41e3a')
          .margin({ top: 20 })

        Text('龙马精神')
          .fontSize(20)
          .fontColor('#1e293b')
          .margin({ top: 8 })

        Text('愿你在新的一年里')
          .fontSize(14)
          .fontColor('#64748b')
          .margin({ top: 24 })

        Text('事业有成,阖家幸福')
          .fontSize(14)
          .fontColor('#64748b')
          .margin({ top: 4 })
      }
      .margin({ top: 40 })
    }
    .width(280)
    .height(380)
    .backgroundColor(Color.White)
    .borderRadius(20)
    .shadow({ radius: 20, color: 'rgba(0, 0, 0, 0.2)', offsetY: 10 })
    .scale({ x: this.cardScale, y: this.cardScale })
    .opacity(this.cardOpacity)
    .translate({ y: this.cardOffsetY })
  }

  showCard() {
    this.isCardVisible = true;
    animateTo({ duration: 400, curve: Curve.Spring }, () => {
      this.cardScale = 1;
      this.cardOpacity = 1;
      this.cardOffsetY = 0;
      this.maskOpacity = 0.5;
    });
  }

  hideCard() {
    animateTo({
      duration: 250,
      curve: Curve.EaseIn,
      onFinish: () => {
        this.isCardVisible = false;
        this.cardScale = 0.8;
        this.cardOpacity = 0;
        this.cardOffsetY = 100;
        this.maskOpacity = 0;
      }
    }, () => {
      this.cardScale = 0.8;
      this.cardOpacity = 0;
      this.cardOffsetY = 100;
      this.maskOpacity = 0;
    });
  }
}

@Builder
export function Lesson08PageBuilder() {
  Lesson08Page()
}

---

本课小结

核心知识点

知识点 说明
animateTo 显式动画,包裹状态变化
Curve 动画曲线:Linear/EaseOut/Spring 等
transition 转场动画,组件出现/消失时触发
TransitionEffect 转场效果:OPACITY/scale/translate
TapGesture 点击手势
LongPressGesture 长按手势
PanGesture 拖动手势

动画曲线选择

场景 推荐曲线
弹出效果 Curve.Spring
收起效果 Curve.EaseIn
通用过渡 Curve.EaseOut
匀速动画 Curve.Linear

---

课后练习

练习1:添加卡片旋转动画

在卡片弹出时添加轻微旋转:

@State cardRotate: number = -5;

// 显示时
this.cardRotate = 0;

// 应用
.rotate({ angle: this.cardRotate })

练习2:实现点赞动画

点击按钮时,心形图标放大后缩小:

animateTo({ duration: 150 }, () => {
  this.heartScale = 1.3;
});
setTimeout(() => {
  animateTo({ duration: 150 }, () => {
    this.heartScale = 1;
  });
}, 150);

---

下一课预告

第9课我们将学习组件状态管理,包括:

  • @State 组件内部状态
  • @Prop 父传子单向数据流
  • @Link 父子双向绑定
  • @Provide/@Consume 跨层级传递

项目开源地址

https://gitcode.com/daleishen/gujinzhijian

Logo

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

更多推荐