写在前面
单词卡、问答卡、商品介绍,都有把另一份信息放到背面的场景。点一下翻过来,比另开一页轻一些,也能让内容之间的关系更直观。这篇就用 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,翻到背面后再点卡片返回。下面这一帧取自返回过程:卡片正在侧转,背面的文字跟着卡片变窄,控制区显示“正在翻面”。它是模拟器当时的截图,不是单独绘制的中间示意图。
翻到背面并停下后,绿色卡片上的标题和两行说明可以正常阅读,控制区也切换为“当前:背面”。正反面内容没有同时叠在一起。
另外实际核对了动画中连续点击卡片,以及恢复默认。连续点击期间只完成一次翻转;复位后回到正面,次数归零,时长和透视恢复初始值。截图展示状态变化,没有据此测量动画帧率。
这张卡还能怎么用
翻转效果完成之后,更值得考虑的是背面放什么。短答案、几项参数、补充说明比较适合;长篇正文塞进背面,读者还是要滚动,翻面的轻巧感就不明显了。
这个实现把内容数据、当前面和旋转角度分开。继续扩展时,可以先增加几组问答数据,再考虑左右切换;不要一开始就把自动翻转、切换数据和多张卡片同时播放全部接进来,那样很难判断一次点击到底改变了什么。
本篇留下了两项观察控件,是为了看清时长与透视的作用。真正放进产品页面时,可以选定合适的参数,把调节区收起来,保留卡片点击和明确的返回方式。