如果你需要一个视力训练工具,可以去鸿蒙应用市场搜一下**「明眸集」**,下载下来体验体验。眼球跟随运动、焦点切换训练、视野扩展练习------这些训练都需要流畅的属性动画来引导视线移动。体验完了再回来看这篇文章,你会更清楚属性动画的实现方式。
写在前面
大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。
很多人觉得"前端转鸿蒙"应该很容易------都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂。
比如:
- 属性动画 :Web里CSS
transition直接声明"哪些属性需要动画";鸿蒙里animateTo也是声明式的,但它是把状态变更包裹在回调函数里,框架自动识别哪些属性变了。 - 动画控制 :Web的CSS动画控制粒度有限;鸿蒙的
@ohos.animator提供更精细的帧级控制。 - 组合动画 :Web可以用
animation同时应用多个动画;鸿蒙里可以在一个animator的frame回调里同时更新多个属性。
但别担心,核心思想是一样的:都是基于时间的状态驱动。你之前积累的前端经验,在鸿蒙里依然是你的核心竞争力。
这篇文章聊什么
明眸集这个App需要做多种属性动画:
- 眼球跟随:一个点从屏幕一端移动到另一端,引导用户视线跟随
- 焦点切换:多个点依次出现和消失,训练焦点切换能力
- 缩放动画:物体从小变大再变小,训练调节能力
这些动画的核心都是属性动画------控制物体的位置、大小、透明度等属性随时间变化。
第一步:理解两种动画方式
鸿蒙里有两种做属性动画的方式:
方式一:animateTo(声明式)
typescript
@Entry
@Component
struct AnimateToDemo {
@State offsetX: number = 0
build() {
Column() {
Circle()
.width(50)
.height(50)
.fill('#10B981')
.translate({ x: this.offsetX })
Button('移动')
.onClick(() => {
// animateTo:把状态变更包裹在回调里
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.offsetX = this.offsetX === 0 ? 200 : 0
})
})
}
}
}
animateTo的特点:
- 声明式:你只管改状态,框架自动做过渡动画
- 简单:不需要手动管理动画实例
- 适合:简单的属性过渡,比如位置、大小、透明度
方式二:@ohos.animator(命令式)
typescript
import { animator, AnimatorResult } from '@ohos.animator'
@Entry
@Component
struct AnimatorDemo {
@State offsetX: number = 0
private anim: AnimatorResult | null = null
aboutToDisappear() {
if (this.anim) {
this.anim.cancel()
this.anim = null
}
}
startAnimation() {
this.anim = animator.create({
duration: 2000,
iterations: -1,
direction: 'Alternate',
curve: 'EaseInOut',
frame: (value: number) => {
// value从0到1,每2秒一个周期
this.offsetX = value * 200
}
})
this.anim.play()
}
build() {
Column() {
Circle()
.width(50)
.height(50)
.fill('#10B981')
.translate({ x: this.offsetX })
Button('开始')
.onClick(() => this.startAnimation())
}
}
}
animator的特点:
- 命令式:你在
frame回调里手动计算每一帧的状态 - 灵活:可以做复杂的非线性动画、多属性联动
- 适合:需要精细控制的动画,比如循环、交替、多阶段
第二步:animateTo详解
animateTo是鸿蒙推荐的属性动画方式,用法类似CSS的transition:
typescript
animateTo({
duration: 500, // 动画时长(毫秒)
curve: 'EaseInOut', // 缓动曲线
delay: 0, // 延迟(毫秒)
iterations: 1, // 播放次数
playMode: PlayMode.Normal, // 播放模式
onFinish: () => {
// 动画完成回调
}
}, () => {
// 状态变更回调
this.offsetX = 200
this.scale = 1.5
this.opacity = 0.5
})
在这个回调里修改的任何@State属性,只要前后值不同,都会自动产生过渡动画。
typescript
@Entry
@Component
struct AnimateToFullDemo {
@State offsetX: number = 0
@State offsetY: number = 0
@State scale: number = 1
@State opacity: number = 1
@State rotation: number = 0
@State color: string = '#10B981'
moveRight() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.offsetX = 200
})
}
moveDown() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.offsetY = 200
})
}
scaleUp() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.scale = 2
})
}
fadeOut() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.opacity = 0.3
})
}
rotate() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.rotation = 90
})
}
changeColor() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.color = '#EF4444'
})
}
reset() {
animateTo({ duration: 500, curve: 'EaseInOut' }, () => {
this.offsetX = 0
this.offsetY = 0
this.scale = 1
this.opacity = 1
this.rotation = 0
this.color = '#10B981'
})
}
build() {
Column() {
// 动画对象
Circle()
.width(80)
.height(80)
.fill(this.color)
.opacity(this.opacity)
.scale({ x: this.scale, y: this.scale })
.translate({ x: this.offsetX, y: this.offsetY })
.rotate({ angle: this.rotation })
// 控制按钮
Column() {
Row() {
Button('右移')
.onClick(() => this.moveRight())
Button('下移')
.onClick(() => this.moveDown())
.margin({ left: 8 })
}
Row() {
Button('放大')
.onClick(() => this.scaleUp())
Button('淡出')
.onClick(() => this.fadeOut())
.margin({ left: 8 })
}
.margin({ top: 8 })
Row() {
Button('旋转')
.onClick(() => this.rotate())
Button('变色')
.onClick(() => this.changeColor())
.margin({ left: 8 })
}
.margin({ top: 8 })
Button('重置')
.onClick(() => this.reset())
.margin({ top: 8 })
}
.margin({ top: 40 })
}
.width('100%')
.height('100%')
.backgroundColor('#111827')
.padding(16)
.justifyContent(FlexAlign.Center)
}
}
第三步:用animateTo做眼球跟随动画
明眸集的核心训练之一是眼球跟随------一个点从屏幕一端移动到另一端,引导用户视线跟随。
typescript
@Entry
@Component
struct EyeFollowPage {
@State dotX: number = 0
@State dotY: number = 0
@State isRunning: boolean = false
private screenWidth: number = 360
private screenHeight: number = 600
startFollow() {
this.isRunning = true
this.moveRight()
}
moveRight() {
animateTo({
duration: 2000,
curve: 'EaseInOut',
onFinish: () => {
if (this.isRunning) this.moveLeft()
}
}, () => {
this.dotX = this.screenWidth - 50
})
}
moveLeft() {
animateTo({
duration: 2000,
curve: 'EaseInOut',
onFinish: () => {
if (this.isRunning) this.moveDown()
}
}, () => {
this.dotX = 0
})
}
moveDown() {
animateTo({
duration: 1000,
curve: 'EaseInOut',
onFinish: () => {
if (this.isRunning) this.moveRight()
}
}, () => {
this.dotY = this.dotY + 100 > this.screenHeight - 50 ? 0 : this.dotY + 100
})
}
stopFollow() {
this.isRunning = false
animateTo({ duration: 300 }, () => {
this.dotX = 0
this.dotY = 0
})
}
build() {
Stack() {
// 跟随点
Circle()
.width(40)
.height(40)
.fill('#10B981')
.translate({ x: this.dotX, y: this.dotY })
.shadow({ radius: 20, color: 'rgba(16, 185, 129, 0.5)' })
// 控制按钮
Column() {
Button(this.isRunning ? '停止' : '开始')
.onClick(() => {
if (this.isRunning) {
this.stopFollow()
} else {
this.startFollow()
}
})
}
.width('100%')
.position({ x: 0, y: this.screenHeight - 80 })
}
.width('100%')
.height('100%')
.backgroundColor('#111827')
}
}
这个动画的逻辑:
- 点从左向右移动(2秒)
- 到达右端后从右向左移动(2秒)
- 到达左端后向下移动一行(1秒)
- 重复以上步骤
用animateTo的onFinish回调串联多个动画。
第四步:用animator做更复杂的动画
如果动画逻辑更复杂,比如需要根据时间计算非线性运动,用animator更合适:
typescript
@Entry
@Component
struct ComplexAnimationPage {
@State dotX: number = 0
@State dotY: number = 0
@State dotScale: number = 1
@State trail: Array<{ x: number, y: number }> = []
private anim: AnimatorResult | null = null
aboutToDisappear() {
if (this.anim) {
this.anim.cancel()
this.anim = null
}
}
startAnimation() {
this.anim = animator.create({
duration: 4000,
iterations: -1,
curve: 'Linear',
frame: (value: number) => {
// 螺旋运动
const angle = value * Math.PI * 4 // 两圈
const radius = 50 + value * 100 // 半径逐渐增大
this.dotX = 180 + radius * Math.cos(angle)
this.dotY = 300 + radius * Math.sin(angle)
// 呼吸效果
this.dotScale = 1 + 0.3 * Math.sin(value * Math.PI * 8)
// 记录轨迹
if (this.trail.length > 50) {
this.trail.shift()
}
this.trail.push({ x: this.dotX, y: this.dotY })
}
})
this.anim.play()
}
build() {
Stack() {
// 轨迹
ForEach(this.trail, (point: { x: number, y: number }, index: number) => {
Circle()
.width(4)
.height(4)
.fill('#10B981')
.opacity(index / this.trail.length * 0.5)
.translate({ x: point.x - 2, y: point.y - 2 })
})
// 主点
Circle()
.width(30 * this.dotScale)
.height(30 * this.dotScale)
.fill('#10B981')
.translate({ x: this.dotX - 15, y: this.dotY - 15 })
.shadow({ radius: 10, color: 'rgba(16, 185, 129, 0.5)' })
Button('开始')
.onClick(() => this.startAnimation())
.position({ x: 130, y: 550 })
}
.width('100%')
.height('100%')
.backgroundColor('#111827')
}
}
这个动画用animator在frame回调里用三角函数计算螺旋运动轨迹,同时驱动位置和大小两个属性。
第五步:animateTo vs animator的选择
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 简单的属性过渡 | animateTo |
代码简洁,声明式 |
| 循环动画 | animator |
iterations: -1 |
| 多属性联动 | animator |
一个回调控制所有属性 |
| 非线性运动 | animator |
可以用数学函数计算 |
| 暂停/恢复 | animator |
提供pause()/resume() |
| 链式动画 | animateTo |
用onFinish回调串联 |
总结
这篇文章围绕"明眸集"的视力训练需求,讲了鸿蒙里两种属性动画方式:
animateTo(声明式)
- 把状态变更包裹在回调里,框架自动做过渡动画
- 适合简单的属性过渡
- 用
onFinish回调串联多个动画
animator(命令式)
- 在
frame回调里手动计算每一帧的状态 - 适合复杂的、需要精细控制的动画
- 提供
play()/pause()/resume()/cancel()控制方法
注意事项
animateTo只对回调里修改的状态生效animator的frame回调里修改的状态不会自动做过渡- 两种方式可以混用
- 动画实例必须在
aboutToDisappear里取消/释放
下一篇文章我会讲明眸集的路径运动------物体沿指定路径移动的实现。