写在前面
单词卡、问答卡、商品介绍,都有把另一份信息放到背面的场景。点一下翻过来,比另开一页轻一些,也能让内容之间的关系更直观。这篇就用 ArkUI 做一张卡:正面放问题,背面放答案,点击后绕竖轴翻转。
卡片的两面好画,麻烦在转动的中间。两份文字如果一直叠着,又一起做透明度动画,转到一半可能会看到重影。这里采用分两段旋转的办法,到侧面时才换内容,让每一段都只显示一面。
如果想先看看这种交互,可以打开 David DeSandro 的
Card Flip · Intro to CSS 3D transforms
。那份示例使用 CSS,本文用 ArkUI 实现;正反面和旋转容器分开处理的思路值得参考。
本篇对应 MyApplication17。页面还保留了时长和透视距离的调节,想看中间过程就把动画放慢,不必回到源码里反复改常量。
转到侧面的时候再换
先把一圈旋转拆开看:正面从 0° 转到 90°,卡片逐渐侧向屏幕;换成背面后,再从 90° 转到 180°。返回时沿相反的路径走。
代码中的 showingBack 决定当前构建哪一面,angle 决定整个卡片容器转到哪里。两者分开,才能在第一段动画结束之后换内容。这里的切换发生在侧向位置,卡片已经很窄,内容替换对画面的影响也小。
背面内部预先绕 Y 轴旋转 180°。等外层也转到 180°,文字就朝向屏幕。缺少内部这次预转,翻过去的背面文字会像从纸后面看过去一样。

两段动画各占总时长的一半。第一段使用 EaseIn,第二段使用 EaseOut:开始慢一些,到侧面附近加快,展开背面时再逐渐停下。它不是用两个定时器猜"什么时候该换面",而是在第一段的 onFinish 中切换,再启动第二段。
this.getUIContext().animateTo() 把角度变化提交给当前页面的动画上下文。相关接口可以查 UIContext 的 animateTo 说明。本文没有自己循环计算每一帧的角度。
一次翻转没有完成时,turning 会拦住新的点击,时长和透视控件也暂时禁用。这样第一段回调不会被下一次操作打乱。翻转次数只在第二段结束后累加,点下按钮时还不算完成。
两面共用一张卡的尺寸
正反面使用相同的卡片布局:宽 300 vp、高 380 vp,标签、标题、说明、提示文字放在各自的数据中。想改成单词卡,可以把正面换成单词,背面换成释义,翻转部分继续使用。
下面这份 entry/src/main/ets/common/FlipCardData.ets 保存两面的内容与配色。它不保存当前角度,也不负责启动动画。
export interface CardFaceData {
tag: string;
title: string;
subtitle: string;
lines: string[];
firstColor: string;
lastColor: string;
}
export const FRONT: CardFaceData = {
tag: 'ARKUI / FRONT',
title: '把问题翻过来',
subtitle: '一张卡片,两种信息',
lines: ['组件绕 Y 轴旋转', '点一下,看看背面'],
firstColor: '#253B7B',
lastColor: '#7357BB'
};
export const BACK: CardFaceData = {
tag: 'ARKUI / BACK',
title: '答案在这里',
subtitle: '先转到侧面,再换内容',
lines: ['背面预先旋转 180°', '动画结束后允许再次翻转'],
firstColor: '#0C776D',
lastColor: '#1A454D'
};
export const PERSPECTIVES: number[] = [500, 900, 1500];
export function faceColors(face: CardFaceData): Array<[ResourceColor, number]> {
return [[face.firstColor, 0], [face.lastColor, 1]];
}
卡片的旋转轴写成 x: 0, y: 1, z: 0,旋转中心放在宽高的一半。perspective 为这次旋转加入透视,页面提供了 500、900、1500 vp 三个选项,可以在相同尺寸的卡片上比较。
这个参数表示透视距离,不是旋转角度。距离较近时,转动中两侧的远近差异更明显;数值变大,透视变化相对缓和。参数定义可对照 ArkUI 组件变换属性。没有必要给所有尺寸的卡片都套用同一个数值。

透视加在外层,背面内部的 180° 预转只负责把内容朝向摆正。两层各做一件事,换正反面内容时不必分别维护两套透视参数。
入口页面的完整代码
把下面内容放入 entry/src/main/ets/pages/Index.ets。卡片本身和"翻到背面"按钮调用同一个 flip(),两条操作路径都经过翻转中的检查。
import { BACK, CardFaceData, faceColors, FRONT, PERSPECTIVES } from '../common/FlipCardData';
@Entry
@Component
struct Index {
@State angle: number = 0;
@State showingBack: boolean = false;
@State turning: boolean = false;
@State duration: number = 700;
@State perspective: number = 900;
@State flips: number = 0;
@State viewportWidth: number = 800;
private alive: boolean = true;
aboutToAppear(): void {
this.alive = true;
}
aboutToDisappear(): void {
this.alive = false;
}
private flip(): void {
if (this.turning) {
return;
}
const nextBack = !this.showingBack;
const half = Math.floor(this.duration / 2);
this.turning = true;
this.getUIContext().animateTo({
duration: half,
curve: Curve.EaseIn,
onFinish: () => {
if (!this.alive) {
return;
}
this.showingBack = nextBack;
this.getUIContext().animateTo({
duration: half,
curve: Curve.EaseOut,
onFinish: () => {
if (!this.alive) {
return;
}
this.turning = false;
this.flips++;
}
}, () => {
this.angle = nextBack ? 180 : 0;
});
}
}, () => {
this.angle = 90;
});
}
private reset(): void {
if (this.turning) {
return;
}
this.angle = 0;
this.showingBack = false;
this.duration = 700;
this.perspective = 900;
this.flips = 0;
}
@Builder
cardFace(face: CardFaceData) {
Column({ space: 18 }) {
Text(face.tag)
.fontSize(12)
.letterSpacing(2)
.fontColor('#DBE6FF')
Blank()
Text(face.title)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(face.subtitle)
.fontSize(15)
.fontColor('#FFFFFF')
Column({ space: 8 }) {
ForEach(face.lines, (line: string) => {
Text(line)
.fontSize(13)
.fontColor('#FFFFFF')
}, (line: string) => line)
}
.alignItems(HorizontalAlign.Start)
.padding(16)
.width('100%')
.backgroundColor('#33101E39')
.borderRadius(14)
Blank()
Text('点击卡片翻面 →')
.fontSize(13)
.fontColor('#DBE6FF')
}
.alignItems(HorizontalAlign.Start)
.padding(28)
.width(300)
.height(380)
.linearGradient({ angle: 135, colors: faceColors(face) })
.borderRadius(24)
.clip(true)
}
@Builder
flipCard() {
Stack() {
if (this.showingBack) {
Column() {
this.cardFace(BACK)
}
.rotate({ x: 0, y: 1, z: 0, angle: 180 })
} else {
this.cardFace(FRONT)
}
}
.width(300)
.height(380)
.rotate({
x: 0, y: 1, z: 0,
angle: this.angle,
centerX: '50%', centerY: '50%',
perspective: this.perspective
})
.onClick(() => {
this.flip();
})
}
@Builder
controls() {
Column({ space: 20 }) {
Text(this.turning ? '正在翻面...' : (this.showingBack ? '当前:背面' : '当前:正面'))
.fontSize(23)
.fontWeight(FontWeight.Bold)
.fontColor('#24344C')
Text('已完成 ' + this.flips.toString() + ' 次翻转')
.fontSize(14)
.fontColor('#6D7C91')
Column({ space: 6 }) {
Text('动画时长:' + this.duration.toString() + ' ms')
.fontSize(14)
.fontColor('#24344C')
Slider({ value: this.duration, min: 400, max: 2000, step: 100 })
.width('100%')
.enabled(!this.turning)
.selectedColor('#6352AB')
.onChange((value: number) => {
this.duration = value;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
Text('透视距离:' + this.perspective.toString() + ' vp')
.fontSize(14)
.fontColor('#24344C')
Row({ space: 8 }) {
ForEach(PERSPECTIVES, (value: number) => {
Button(value.toString())
.fontSize(13)
.height(36)
.fontColor(this.perspective === value ? '#FFFFFF' : '#6352AB')
.backgroundColor(this.perspective === value ? '#6352AB' : '#EDE9F7')
.enabled(!this.turning)
.onClick(() => {
this.perspective = value;
})
}, (value: number) => value.toString())
}
Text('缩短距离,比较卡片转动时的透视变化。放慢动画,可以更容易看到中间过程。')
.fontSize(13)
.lineHeight(22)
.fontColor('#6D7C91')
Row({ space: 10 }) {
Button(this.showingBack ? '翻回正面' : '翻到背面')
.fontSize(14)
.height(40)
.backgroundColor('#6352AB')
.enabled(!this.turning)
.onClick(() => {
this.flip();
})
Button('恢复默认')
.fontSize(14)
.height(40)
.fontColor('#6352AB')
.backgroundColor('#EDE9F7')
.enabled(!this.turning)
.onClick(() => {
this.reset();
})
}
}
.alignItems(HorizontalAlign.Start)
.padding(26)
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(24)
}
build() {
Scroll() {
Column({ space: 28 }) {
Column({ space: 8 }) {
Text('翻一下,另一面')
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor('#24344C')
Text('ArkUI 的组件旋转,也能做一张有正反面的卡片')
.fontSize(14)
.fontColor('#6D7C91')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
if (this.viewportWidth >= 700) {
Row({ space: 48 }) {
this.flipCard()
Column() { this.controls() }.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
this.flipCard()
this.controls()
}
}
.padding(32)
.width('100%')
.constraintSize({ maxWidth: 950 })
}
.width('100%')
.height('100%')
.backgroundColor('#F3F1F8')
.onAreaChange((_old: Area, area: Area) => {
this.viewportWidth = Number(area.width);
})
}
}
reset() 恢复正面、默认时长和透视距离,同时将次数清零。它也检查 turning,因此不会在一段动画还没有结束时突然把角度归零。
两个完成回调还检查了 alive。页面离开后,不再接着启动第二段或更新完成次数。这是给当前演示页面的回调做收尾,并没有加入自动翻转或后台播放。
宽窗口里卡片和控制区左右摆放,窗口较窄时上下排列,外层可以滚动。卡片的尺寸保持固定,透视参数的比较更容易观察。本文实际运行验证使用 PC 模拟器的宽窗口。
正面、侧面、背面都看一下
本次将全量编译得到的 HAP 安装到模拟器,实际启动的是 com.example.flipcard 的 EntryAbility,页面入口为 pages/Index。刚打开时显示正面,默认时长为 700 ms,透视距离为 900 vp。

把时长放慢到 2000 ms,透视距离改成 500 vp,翻到背面后再点卡片返回。下面这一帧取自返回过程:卡片正在侧转,背面的文字跟着卡片变窄,控制区显示"正在翻面"。它是模拟器当时的截图,不是单独绘制的中间示意图。

翻到背面并停下后,绿色卡片上的标题和两行说明可以正常阅读,控制区也切换为"当前:背面"。正反面内容没有同时叠在一起。

另外实际核对了动画中连续点击卡片,以及恢复默认。连续点击期间只完成一次翻转;复位后回到正面,次数归零,时长和透视恢复初始值。截图展示状态变化,没有据此测量动画帧率。
这张卡还能怎么用
翻转效果完成之后,更值得考虑的是背面放什么。短答案、几项参数、补充说明比较适合;长篇正文塞进背面,读者还是要滚动,翻面的轻巧感就不明显了。
这个实现把内容数据、当前面和旋转角度分开。继续扩展时,可以先增加几组问答数据,再考虑左右切换;不要一开始就把自动翻转、切换数据和多张卡片同时播放全部接进来,那样很难判断一次点击到底改变了什么。
本篇留下了两项观察控件,是为了看清时长与透视的作用。真正放进产品页面时,可以选定合适的参数,把调节区收起来,保留卡片点击和明确的返回方式。