在 HarmonyOS 6 上做了张 3D 翻转卡片,正面背面来回翻

写在前面

单词卡、问答卡、商品介绍,都有把另一份信息放到背面的场景。点一下翻过来,比另开一页轻一些,也能让内容之间的关系更直观。这篇就用 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,翻到背面后再点卡片返回。下面这一帧取自返回过程:卡片正在侧转,背面的文字跟着卡片变窄,控制区显示"正在翻面"。它是模拟器当时的截图,不是单独绘制的中间示意图。

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

另外实际核对了动画中连续点击卡片,以及恢复默认。连续点击期间只完成一次翻转;复位后回到正面,次数归零,时长和透视恢复初始值。截图展示状态变化,没有据此测量动画帧率。

这张卡还能怎么用

翻转效果完成之后,更值得考虑的是背面放什么。短答案、几项参数、补充说明比较适合;长篇正文塞进背面,读者还是要滚动,翻面的轻巧感就不明显了。

这个实现把内容数据、当前面和旋转角度分开。继续扩展时,可以先增加几组问答数据,再考虑左右切换;不要一开始就把自动翻转、切换数据和多张卡片同时播放全部接进来,那样很难判断一次点击到底改变了什么。

本篇留下了两项观察控件,是为了看清时长与透视的作用。真正放进产品页面时,可以选定合适的参数,把调节区收起来,保留卡片点击和明确的返回方式。

相关推荐
Solange__1 小时前
工业产线怎么用AI生成3D场景?从现场采集、部件重建到数字孪生交付的完整方案
人工智能·游戏·3d·ai·语言模型·自然语言处理·关卡设计
小π军2 小时前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos
m0_738185822 小时前
Flutter 鸿蒙化实战:volume_controller 适配 OpenHarmony,系统音量控制
flutter·华为·harmonyos·鸿蒙
用户117910488332 小时前
每个会话一个子进程:多会话真并行的架构取舍
开源·harmonyos
Martina_03212 小时前
一张地图怎么生成可编辑3D地形?用6步检查高度图、道路约束与Mesh导出
图像处理·人工智能·平面·3d·ai·自然语言处理·游戏策划
m0_738185823 小时前
Flutter 鸿蒙化实战:webview_cookie_manager 适配 OpenHarmony,WebView Cookie 管理
flutter·华为·harmonyos·鸿蒙
垆边人似月.3 小时前
华为题:编辑距离
算法·华为
Shirley~~3 小时前
从0搭建一个3D立体场景:光影与交互全解析
前端·3d
Zldaisy3d3 小时前
总重仅28.9克!3D打印钛合金镜腿为运动AR眼镜电子元件腾出重量空间
3d·制造