前言

「诗境」的刷卡页把一整个年级的诗铺成一叠牌:当前那张居中、左右各露一角,手指划过 100vp 就翻到下一张。这个「牌堆」不是真正的列表容器,而是 111 个(或某个年级的十几二十个)卡片组件按 index - currentIndex 算出的三组几何参数同时叠在同一个 Stack 里。五个常量决定了它看起来像不像一叠纸。

alt text

五个常量定义一堆牌

const CARD_SIDE_OFFSET: number = 50;
const CARD_SIDE_OPACITY_STEP: number = 0.3;
const CARD_SIDE_SCALE_STEP: number = 0.12;
const CARD_ANIMATE_DURATION: number = 500;
const CARD_SWIPE_THRESHOLD: number = 100;

前三个是几何,第四个是动画时长,第五个是手势阈值。它们全部是模块级常量,不在组件里 —— 这五个数字是这套交互的「设计参数」,改一个就会改变整叠牌的手感,放在文件头比藏在 build 里更容易对照。

三个几何参数各自对应一种视觉线索:

距离水平偏移透明度缩放
0(当前卡)01.01.00
1(相邻卡)±500.70.88
2 及以上±100 / ±150 …00.88

偏移告诉用户「后面还有多少张」,透明度告诉用户「哪张是当前」,缩放告诉用户「哪张在最上面」。三条线索冗余表达同一件事,是因为单靠透明度区分层次会让侧卡看起来「半透明」而不是「在后面」;单靠偏移会让侧卡与背景糊在一起;单靠缩放则在小屏上几乎看不出 12% 的差别。

distance > 1 的两个分支都直接返回常量:

  private getCardOpacity(index: number): number {
    const distance: number = Math.abs(index - this.currentIndex);
    if (distance > 1) {
      return 0;
    }
    return 1 - CARD_SIDE_OPACITY_STEP * distance;
  }

  private getCardScale(index: number): number {
    const distance: number = Math.abs(index - this.currentIndex);
    if (distance > 1) {
      return 1 - CARD_SIDE_SCALE_STEP;
    }
    return 1 - CARD_SIDE_SCALE_STEP * distance;
  }

opacity 在距离 2 时直接归零,不做 1 - 0.3 * 2 = 0.4 的线性递减。如果按公式算,第三张卡会是 0.4 的透明度,第四张 0.1,第五张负数 —— 一叠越来越淡的卡确实像牌堆,但它的代价是五张卡的文字全部参与渲染与合成。归零只保留相邻一张可见,视觉上从「能看到四点五层」变成「能看到三层」(当前 + 左右),层次感弱了一点,渲染成本降了一个量级。

scale 在距离大于 1 时不再继续缩,而是钉在 0.88。因为它已经透明了,继续缩没有任何视觉意义 —— 这个分支存在的唯一目的是避免负数:如果照公式线性递减,距离 9 时 1 - 0.12 * 9 = -0.08,负缩放会让组件翻面,虽然不可见但会参与布局计算。

getCardOffsetX 没有这个特殊分支:

  private getCardOffsetX(index: number): number {
    return CARD_SIDE_OFFSET * (index - this.currentIndex);
  }

偏移可以无限增长,距离 10 就是 500vp —— 早已移出屏幕,Stack 默认裁掉。它不需要保护,因为一个越界的 translate 不会引发任何计算异常,只是把组件挪到看不见的地方。

zIndex 用透明度而不是索引

            ForEach(this.poems, (poem: Poem, index: number) => {
              PoemCard({
                poem: poem,
                isCollected: index === this.currentIndex ? this.isCollected : false,
                isMastered: index === this.currentIndex ? this.isMastered : false,
                showMeaning: this.showMeaning,
                isSpeaking: this.isSpeaking,
                themeIndex: index % PoemCardThemes.THEME_COUNT,
                onSpeak: () => {
                  this.speakCurrent();
                },
                onToggleCollect: () => {
                  this.toggleCollect();
                },
                onToggleMastered: () => {
                  this.toggleMastered();
                },
                onFlipCard: () => {
                  this.flipCard();
                }
              })
                .translate({ x: this.getCardOffsetX(index) })
                .opacity(this.getCardOpacity(index))
                .scale({ x: this.getCardScale(index), y: this.getCardScale(index) })
                .zIndex(this.getCardOpacity(index))
            }, (poem: Poem) => 'poem_' + poem.id)

.zIndex(this.getCardOpacity(index)) 这一行很容易被误读成「用透明度当层级」。实际效果是:当前卡 zIndex = 1,相邻卡 0.7,其余 0。三条通道单调对应同一个距离,所以用哪个都能排出正确的栈序。

用透明度的好处是「可见度最高的那张一定在最上面」这条不变式由同一个表达式保证。如果改成 zIndex(index),当前卡在中间位置(比如第 15 张)时,它右侧的卡 zIndex 更大,会盖在它上面 —— 哪怕那些卡是透明的,只要有一丝阴影溢出,边缘就会压在当前卡上。用 -distance 也能表达同样的序,但那要多写一个取反,而 opacity 已经是现成的「距离的单调递减函数」。

侧卡的收藏/已学会状态被硬写成 false:

                isCollected: index === this.currentIndex ? this.isCollected : false,

页面只有一组 isCollected / isMastered,描述的是「当前这张卡」。侧卡不该显示状态 —— 它们露出的那一角如果带着星星图标,会让人误以为那些诗也被收藏了。等它滑到中间,index === currentIndex 成立,真实状态才传进去。

themeIndex: index % PoemCardThemes.THEME_COUNT 让四套宣纸配色按顺序循环。这是个好细节:卡片左右滑动时背景色在四种之间轮转,用户不用看右下角的计数,光凭颜色就知道「换了一首」。

整个 ForEach 没有虚拟化 —— 111 首诗就是 111 个 PoemCard 同时挂载。每个卡片内部有 Scroll、两个 Button、若干 Text,总数上千个节点。这个规模在当前设备上还能接受,但它是这个页面唯一不能继续加功能的地方:如果以后要给卡片加更重的子组件,第一件要做的事是把 ForEach 换成只渲染 [currentIndex - 1, currentIndex, currentIndex + 1] 三个。

alt text

手势是触发器,不是拖拽

          .gesture(
            PanGesture(this.panOption)
              .onActionUpdate((event: GestureEvent) => {
                if (event.offsetX < -CARD_SWIPE_THRESHOLD && !this.isSwiping
                  && this.currentIndex < this.poems.length - 1) {
                  this.isSwiping = true;
                  this.nextPoem();
                } else if (event.offsetX > CARD_SWIPE_THRESHOLD && !this.isSwiping
                  && this.currentIndex > 0) {
                  this.isSwiping = true;
                  this.prevPoem();
                }
              })
              .onActionEnd(() => {
                this.isSwiping = false;
              })
              .onActionCancel(() => {
                this.isSwiping = false;
              })
          )

四个条件同时成立才翻页:位移超过阈值、本次手势还没翻过、没到头、方向正确。

panOption 是 new PanGestureOptions({ direction: PanDirection.Horizontal }),只识别水平方向 —— 卡片内部的译文区是 Scroll,纵向滚动不能被手势抢走。

翻页发生在 onActionUpdate 而不是 onActionEnd。这意味着手指还在屏幕上滑、刚过 100vp 的那一刻,页面就已经开始翻页动画。用户不需要「滑到底再松手」,是「划过一条线就翻」。

isSwiping 是这个模式的前提。onActionUpdate 在一根手指的滑动过程中会连续触发几十次,没有这个标志,跨过阈值之后每一次回调都会再调一次 nextPoem,一次滑动翻十张。

标志在 onActionEnd 和 onActionCancel 两处复位。onActionCancel 不能省 —— 手指滑出屏幕边缘、被系统手势打断、来电覆盖,这些情况下 onActionEnd 不一定会来。

卡片不跟随手指移动。translate 的输入是 getCardOffsetX(index),只依赖 currentIndex,与 event.offsetX 无关。所以拖动过程中卡片是静止的,越过阈值后整叠牌按 500ms 的动画滑到新位置。这是「触发器模式」而不是「跟手模式」。

跟手模式需要把 event.offsetX 加进每张卡的 translate,手势结束时再判断是否过半、决定回落还是翻页。视觉上更顺滑,代价是要在拖动期间持续改 111 张卡的位移,而且「过半回落」的动画与「翻页」的动画要用两套参数。

触发器模式的取舍很清楚:手感差一点(手指动了卡片不动),但状态只有一个 currentIndex,任何时刻的渲染结果都只由它决定。

翻页之后才记账

  private nextPoem(): void {
    if (this.currentIndex < this.poems.length - 1) {
      TtsService.getInstance().stop();
      this.getUIContext().animateTo({
        duration: CARD_ANIMATE_DURATION,
        curve: Curve.EaseInOut,
        onFinish: () => {
          this.recordCurrentRead();
        }
      }, () => {
        this.currentIndex++;
        this.refreshCardState();
      });
    } else {
      TtsService.getInstance().stop();
      this.showCelebration = true;
    }
  }

animateTo 的第二个参数是「状态怎么变」,onFinish 是「动画放完之后做什么」。currentIndex++ 在闭包里立刻执行(状态变了,框架据此补间出动画),recordCurrentRead 在 500ms 后才执行。

顺序这样排的理由是「记哪一首」:recordCurrentRead 读的是 this.poems[this.currentIndex],如果在自增之前调,记的是上一首;如果在自增之后立即调,记的是新的一首 —— 但此时新卡还没滑到中间显示,用户还没看到它。放在 onFinish 里,语义就成了「这一首完整地展示过 500ms 之后,算读过一次」。

refreshCardState() 在闭包里和自增一起执行,因为它要在动画开始前把新卡的收藏态、翻转态准备好 —— 动画期间新卡正在滑入,如果翻转态还是上一首留下的,会看到一张翻着面的卡滑进来。

prevPoem 是镜像结构,边界判断换成 currentIndex > 0。retryAll 也是同一个骨架,只是把索引归零并清掉庆祝层:

  private retryAll(): void {
    this.getUIContext().animateTo({
      duration: CARD_ANIMATE_DURATION,
      curve: Curve.EaseInOut,
      onFinish: () => {
        this.recordCurrentRead();
      }
    }, () => {
      this.currentIndex = 0;
      this.showCelebration = false;
      this.refreshCardState();
    });
  }

三个翻页入口都以 TtsService.getInstance().stop() 开头(retryAll 除外,它是从庆祝层回来的,本来就没有在念)。上一首的音频念到下一张卡上,是这个交互里最违和的错误。

recordCurrentRead 是唯一的记账点:

  private async recordCurrentRead(): Promise<void> {
    if (this.currentIndex < 0 || this.currentIndex >= this.poems.length) {
      return;
    }
    const poem: Poem = this.poems[this.currentIndex];
    await StorageService.getInstance().incrementRead(poem.id);
  }

它在两处被调用:aboutToAppear 里进入页面时一次(进入就算读过第一首),以及每次翻页动画结束时一次。incrementRead 是 StorageService 里唯一不触发卡片刷新的写操作 —— 翻卡会高频调用它,每次都 updateForm 会撞上系统的刷新配额。所以「已学」这个统计在卡片上不会实时变,只在下次收藏/学会/切年级时顺带更新。

翻面是内容替换,不是 3D 旋转

showMeaning 是页面级的 @Local,传给所有卡片。flipCard() 只做一件事:

  private flipCard(): void {
    this.showMeaning = !this.showMeaning;
  }

卡片内部用两个互斥的 if 决定显示什么:

        // ── 诗文(翻转后隐藏原文,只看译文) ──
        if (!this.showMeaning) {
          Text(this.poem.content)
            .fontSize(22)
            .lineHeight(38)
            ...
        }

        // ── 翻转后显示的译文区(可上下滚动) ──
        if (this.showMeaning) {
          Scroll() {

没有 rotate、没有 transition、没有两面子组件。所谓「翻面」是同一张卡上「诗文」这块内容被换成「译文」这块内容,标题、朝代作者、底部两个按钮全部原地不动。

这个选择带来三个直接后果:翻面没有动画(瞬间切换,if 分支切换不补间);标题与作者始终可见,用户不会怀疑「我是不是换了一首诗」;译文区可以做成 Scroll,装下整段白话译文而不用压缩字号。

如果用真正的 3D 翻转,两面都得按固定高度设计,译文长一点就要么截断要么溢出。内容替换没有这个约束 —— Scroll 加 layoutWeight(1) 吃掉中间所有剩余空间,译文再长也只是多滚两下。

译文区还有一个降级分支:

              if (this.safeTranslation().length > 0) {
                Text(this.safeTranslation())
                  .fontSize(16)
                  .lineHeight(26)
                  ...
              } else {
                // 无译文:按句号拆分逐句展示
                ForEach(this.buildSentenceLines(), (line: string, idx: number) => {
                  Column({ space: 2 }) {
                    Text(line)
                      ...
                    Text('~ 第 ' + (idx + 1) + ' 句 大意请诵读体会 ~')

没有译文时不是显示「暂无译文」,而是把正文按句拆开、逐句排版,每句下面配一句提示。buildSentenceLines 手写了分句:

  private buildSentenceLines(): string[] {
    const result: string[] = [];
    const raw: string = this.poem.content;
    let buffer: string = '';
    for (let i = 0; i < raw.length; i++) {
      const ch: string = raw.charAt(i);
      buffer += ch;
      if (ch === '。' || ch === '?' || ch === '!') {
        result.push(buffer);
        buffer = '';
      }
    }
    if (buffer.length > 0) {
      result.push(buffer);
    }

逐字符扫、遇到三个终止标点就切一刀,最后把残余的 buffer 推入。不按 \n 切是因为词库里的正文用中文标点断句、不保证有换行;不用正则 split(/[。?!]/) 是因为那样会丢掉标点本身,而这里希望每一句带着自己的句号显示。

这一段在当前数据下基本不会执行 —— poems.json 里 111 首全部带 translation。它存在是为了「词库换版本、某首缺译文」时不至于翻面看到空白。safeTranslation 承担同一层意图:

  private safeTranslation(): string {
    if (this.poem.translation === undefined || this.poem.translation === null) {
      return '';
    }
    return this.poem.translation;
  }

Poem 模型里 translation 声明为可选字段,JSON 未填时是 undefined。直接 .length 会抛错,所以先判空再取。

alt text

朗读按钮与「阻止冒泡」

整张卡有 onClick(() => this.onFlipCard()),右上角的朗读按钮在它内部:

          if (this.isSpeaking) {
            Row({ space: 4 }) {
              Image($r('app.media.ic_speaker_play'))
              Text('朗读中…')
            }
            ...
            .onClick(() => {
              this.onSpeak();
            })

组件注释里写着「朗读按钮点击触发 onSpeak(阻止冒泡,避免触发卡片翻转)」,但代码里没有任何「阻止冒泡」的调用。它不需要 —— 点击的命中测试落在最内层可点击组件上,父级的 onClick 不会因为子级被点而触发一次。

真正需要关心的反而是「按钮被点到时卡片不该翻面」这件事已经成立了,所以朗读与收藏、学会、翻面四个操作互不干扰。底部两个 Button 同理。

按钮本身有两种形态:朗读中变成胶囊(图标 + 「朗读中…」文字,宽度由 padding 撑开),静置时是 48×48 的圆形。形态切换由 isSpeaking 驱动,而这个状态来自页面:页面从 AppStorage 轮询到朗读结束,@Param isSpeaking 变化,卡片重建那一小块。所以「朗读中…」这个胶囊是跨组件状态传递的终端显示 —— 它不知道 TTS 的存在,只知道一个布尔值。

完成态与顶部信息

读完最后一首时不再翻页,而是把 showCelebration 置真,在 Stack 上盖一层:

      if (this.showCelebration) {
        Column() {
          Column({ space: 12 }) {
            Image($r('app.media.ic_celebrate_party'))
            Text('本年级诗词读完啦!')

它和 LevelUpDialog 是两个独立的覆盖层,都挂在最外层的 Stack 里,互不知道对方存在。两个同时出现时(最后一首刚好升级)会叠在一起 —— 升级弹窗在上(它后声明,Stack 后声明的在上),用户先看到升级,关掉之后看到完成。

顶部一行是「返回 / 第 N 首共 M 首 / 占位」:

          Blank()
          Text(this.poems.length > 0
            ? `第 ${this.currentIndex + 1} / ${this.poems.length} 首`
            : '暂无诗词')
          Blank()
          Text(' ')

两个 Blank() 把计数挤到中间,但右侧还需要一个与「返回」等宽的占位才能真的居中 —— 用一个全角空格的 Text 顶上。fontSize(15) 与左侧「返回」的字号一致,宽度接近,于是视觉上大致居中了。这是不引入 Stack 或固定宽度容器的最简做法,也是这套布局里唯一一处「用字符当占位」的地方。

进度条用的是系统 Progress:

          Progress({ value: this.currentIndex + 1, total: this.poems.length, type: ProgressType.Linear })
            .width('90%')
            .height(4)
            .color(AppColors.PRIMARY)
            .backgroundColor('#FFE0B2')

value 从 1 开始而不是 0 —— 打开页面就已经在第一首上,进度条应该显示「已读 1 首」而不是空的。下面那行百分比用同一个算式:Math.round((this.currentIndex + 1) / this.poems.length * 100)。两处都用 +1,这样最后一张时正好是 100%,与「完成」按钮出现、庆祝层弹出的时机吻合。

整页最外层是一层竖向渐变:

      .linearGradient({ angle: 180, colors: [['#FFF8E1', 0], ['#FBEED3', 0.65], ['#F5E2C0', 1]] })

三个色标而不是两个,中间 0.65 处插了一个略深的米色,让背景从上到下有一个「纸被光斜照」的过渡。卡片自己的渐变是 160 度、两种颜色(主题色),外边这层是 180 度、三种颜色。两个渐变不共用色值 —— 背景是固定的米黄,卡片随四套主题轮换,所以一张淡青色的卡放在米黄背景上时,两层颜色会形成对比,卡片能「浮」出来。

一句话的取舍

这页的所有视觉层次都由三个数字算出来(偏移 50、透明度步长 0.3、缩放步长 0.12),手势只改一个 currentIndex,翻面只改一个 showMeaning。两个状态、三个派生函数,加上一层动画包裹 —— 这就是整套「刷卡」交互的全部机制,剩下的代码都在处理边界:滑到头、翻到末页、译文缺失、朗读没停。

Logo

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

更多推荐