HarmonyOS 纸感交互实战:把天气卡片做成便利贴撕下效果

HarmonyOS 纸感交互实战:把天气卡片做成便利贴撕下效果

很多首页中间卡片都做得很"完整",但不够"有手感"。

这次我拿一个天气场景里的中部卡片区做实验,没有继续堆渐变和阴影,而是把它改造成了一种更像真实纸张的交互:手指拖动时,卡片像一张贴在桌面上的便利贴,被一点点掀起、撕下、露出下一张。

这篇文章不会只讲视觉结果,而是把完整技术链路拆开:为什么我没有直接做组件形变、为什么最后还是回到"组件截图 + 自绘"这条路、从电子书翻页思路迁移到便利贴撕下时哪些部分可以复用、这套效果背后到底用了哪些几何关系,以及在 HarmonyOS 里真正让它跑顺时踩过的几个典型坑。


一、先说场景:为什么一个天气卡片,值得做成"便利贴撕下"

做首页中间卡片时,最常见的写法其实很标准:

  • 一张大卡片
  • 上面放当前温度、趋势、生活提示
  • 配一点圆角、阴影、玻璃感

它当然没问题,甚至大多数时候已经足够"好看"。

但问题也恰恰在这里。

它很完整,却不够有触感。

如果整个页面的视觉气质更轻一点、更像桌面陈列,而不是标准信息面板,那这张卡片继续保持纯静态展示,用户会很容易只把它当成一个"看完就划走"的区块。

我当时想象的不是"再做一个轮播",也不是"加一点无关痛痒的微动效",而是一个更生活化的交互画面:

早上打开天气页,看到桌面中央压着几张便签。

第一张写着"今天 26°C,空气不错"。

你手指往左一拖,这张便签从右下角被掀起一点,背面露出来,底下第二张"24 小时趋势"慢慢显现;再往前拖,第一张像被顺手撕掉一样离开舞台,第二张稳稳接上。

这种感觉和普通卡片切换最大的区别,不是"更炫",而是更像在操作一个物体

而一旦要把"物体感"做出来,事情就不再只是动画问题了。

它会变成一个很工程的命题:

  • 复杂卡片内容怎么参与变形?
  • 拖动的是组件,还是组件的截图?
  • 卡片背面怎么画?
  • 掀起区域怎么裁?
  • 下层卡片什么时候露出来才自然?
  • 手势和父容器手势冲突时,谁说了算?

这也是为什么我最后没有走纯组件动画路线,而是借用了另一类非常成熟的思路:仿真翻页


二、核心思路:不是"拉扯组件",而是"先把组件拍成一张纸,再去折这张纸"

这次实现真正的转折点,不是我写了某个神奇动画,而是我换了一个思考方式。

一开始很容易想到的是这些手段:

  • 拖动时让卡片跟手位移
  • 叠一点 rotate
  • 再叠一点 scale
  • 最后补一层透明度和投影

这种做法可以让卡片"动起来",但只要你想把它做得更像"纸被掀起",马上就会碰到几个很现实的问题:

  1. 卡片内部内容很复杂,有文本、指标块、渐变高光,不适合跟着做复杂几何变形。
  2. 你真正需要的不是整块旋转,而是局部掀起、局部裁剪、局部露底。
  3. 背面并不是正面的简单镜像缩放,而是要围绕折痕做反射。
  4. 下层内容不是瞬时切换,而是应该在上层被掀起时逐步显露。

所以更稳的方案是:

组件负责摆内容,自绘层负责模拟纸。

也就是:

  1. 页面里先摆好三层静态卡片
  2. 手势开始后,把当前卡片或上一张卡片截成 PixelMap
  3. 把这张截图交给 NodeContainer + RenderNode
  4. 后续拖动过程不再直接操作业务组件,而是操作这张位图

这套结构很像一句很形象的话:

先把页面拍扁成一张纸,再去掀这张纸。

先看一眼最核心的截图动作:

ts 复制代码
private captureSnapshot(cardId: string): boolean {
  this.pagePixelMap?.release();
  this.pagePixelMap = this.getUIContext().getComponentSnapshot().getSync(cardId);
  AppStorage.setOrCreate('flip_card_pixel_map', this.pagePixelMap);
  return this.pagePixelMap !== undefined;
}

再看容器层的大骨架:

ts 复制代码
build() {
  Stack() {
    StickyCard({ card: this.nextCard })

    StickyCard({ card: this.currentCard })
      .id('middleCard')
      .visibility(this.isMiddleCardHidden ? Visibility.None : Visibility.Visible)

    StickyCard({ card: this.previousCard })
      .translate({ x: -this.hostWidthVp })
      .id('leftCard')

    NodeContainer(this.nodeController)
      .width('100%')
      .height('100%')
      .visibility(this.isNodeVisible ? Visibility.Visible : Visibility.None)
  }
}

这里有一个非常重要的工程判断:

不要在拖动时临时创建"下一张卡"。

下一张卡必须一开始就躺在底下。

因为便利贴撕下的观感本质上不是"切换",而是"上面那张离开后,底下那张自然露出来"。

只要这个层次先摆对了,后面的数学和绘制才有意义。


三、从手指到纸张:这条链路里真正发生了什么

这类纸感交互看起来像是一个动画,其实它更像一个不断喂数据的系统。

手指每移动一点,组件层都要把新的几何条件交给绘制层,然后绘制层再根据这组条件重算可见区域、背面区域和阴影。

整个链路可以拆成 4 步。

1. 先判断这次拖动的"意图"

对卡片来说,最重要的不是用户手指现在在哪,而是:

  • 这次想翻到上一张,还是下一张?
  • 这次拖动是从顶部起、从底部起,还是从中间起?

这两个判断决定了后面几何模型该怎么跑。

比如中部拖动,通常更适合走简化模型,让它更像水平撕开;而顶部、底部起拖,则可以保留一点上下卷动的味道。

代码里大致是这样:

ts 复制代码
private initDrawPosition(localYVp: number): void {
  const localYPx: number = this.getUIContext().vp2px(localYVp);
  if (localYPx < this.hostHeightPx / 3) {
    this.drawPosition = DrawPosition.TOP;
  } else if (localYPx > (this.hostHeightPx * 2) / 3) {
    this.drawPosition = DrawPosition.BOTTOM;
  } else {
    this.drawPosition = DrawPosition.MIDDLE;
  }
}

2. 决定截图哪一层

这一步是很多人第一次看这种方案时最容易忽略的地方。

向左拖时,被掀起来的是当前卡片。

向右拖时,被掀起来的则更适合是"上一张卡片的截图"。

也就是说,虽然视觉上你看到的是"左右切换",但被画出来的纸,其实不总是当前层。

ts 复制代码
private beginDrawingByDirection(offsetX: number): boolean {
  if (Math.abs(offsetX) < 3) {
    return false;
  }

  if (offsetX > 0) {
    this.pageMoveDirection = MoveDirection.PREVIOUS;
    this.snapshotCardId = 'leftCard';
    this.drawPosition = DrawPosition.MIDDLE;
  } else {
    this.pageMoveDirection = MoveDirection.NEXT;
    this.snapshotCardId = 'middleCard';
    this.isMiddleCardHidden = true;
  }

  return this.captureSnapshot(this.snapshotCardId);
}

3. 手势层不负责画图,只负责"喂坐标"

这是整套方案里我最喜欢的一点。

组件层其实非常克制,它不做复杂变形,只做三件事:

  • 更新这次拖动的最新方向
  • 把最新触点坐标写到共享状态里
  • 触发一次新的 RenderNode 重绘
ts 复制代码
private handlePanUpdate(event: GestureEvent): void {
  const finger = event.fingerList[0];

  this.positionX = this.getUIContext().vp2px(finger.localX);
  this.positionY = this.getUIContext().vp2px(finger.localY);

  AppStorage.setOrCreate('flip_card_draw_state', DrawState.MOVING);
  AppStorage.setOrCreate('flip_card_position_x', this.positionX);
  AppStorage.setOrCreate('flip_card_position_y', this.positionY);

  this.refreshRenderNode();
}

4. 绘制层每次都根据新坐标重算"纸的形状"

也就是说,用户看到的不是一个固定动画曲线,而是一套实时响应拖动的几何结果。

这也是为什么最后效果会有很强的"物体感"。

因为它不是预制动画在播,而是在实时求解"这张纸现在应该长什么样"。


四、便利贴为什么能被"掀起来":这背后其实是一点很朴素的几何

这一部分是整篇文章最值得展开讲的地方。

很多人会觉得这种效果像魔术,其实它背后没有什么神秘黑箱,核心还是中学几何 + 一点二维图形学。我们把蒙层换为透明度,把下一层的内容先隐藏,你会发现其实我们模拟了一个叠加层,就像下面这样。

我们定义几个核心结构点。

  • A:当前手指控制点
  • F:固定参考点,右上角或右下角(你从下翻还是从上往下翻)
  • GAF 的中点
  • E / H:通过中点和垂线关系推出的控制点
  • B / K:折线与边界辅助线的交点
  • C / J:靠近纸张边缘的辅助点

如果用一句更通俗的话解释:

手指点 A 决定了"你把纸掀到哪了",固定点 F 决定了"纸是绕哪里翻的"。

中点为什么重要?

因为在二维平面上,一条线段的中点往往是构造反射、垂直平分线、折叠轨迹的起点。

公式非常简单:

G_x = \\frac{A_x + F_x}{2}, \\quad G_y = \\frac{A_y + F_y}{2}

接着再用垂线关系求出 EH

当前实现里用的是这种形式:

ts 复制代码
pointG.x = (pointA.x + pointF.x) / 2;
pointG.y = (pointA.y + pointF.y) / 2;

pointE.x = pointG.x - gfY * gfY / safeGfX;
pointE.y = pointF.y;

pointH.x = pointF.x;
pointH.y = pointG.y - gfX * gfX / safeGfY;

你不一定非得死记这几个式子,但要知道它们在干什么:

  • G 把拖动点和固定边联系起来
  • E / H 让我们得到一条可以被当成"折痕参考线"的几何结构
  • 后面的 B / KC / J 则帮助我们构造最终路径

再往下就要用到一个经典小工具:两条直线交点公式

ts 复制代码
function getIntersectionPoint(
  lineOnePointOne: Point,
  lineOnePointTwo: Point,
  lineTwoPointOne: Point,
  lineTwoPointTwo: Point
): Point {
  const x1 = lineOnePointOne.x;
  const y1 = lineOnePointOne.y;
  const x2 = lineOnePointTwo.x;
  const y2 = lineOnePointTwo.y;
  const x3 = lineTwoPointOne.x;
  const y3 = lineTwoPointOne.y;
  const x4 = lineTwoPointTwo.x;
  const y4 = lineTwoPointTwo.y;

  const pointX =
    ((x1 - x2) * (x3 * y4 - x4 * y3) - (x3 - x4) * (x1 * y2 - x2 * y1)) /
    ((x3 - x4) * (y1 - y2) - (x1 - x2) * (y3 - y4));

  const pointY =
    ((y1 - y2) * (x3 * y4 - x4 * y3) - (x1 * y2 - x2 * y1) * (y3 - y4)) /
    ((y1 - y2) * (x3 - x4) - (x1 - x2) * (y3 - y4));

  return new Point(pointX, pointY);
}

这一步看起来很数学,但它在工程里的意义特别具体:

没有这些交点,你就画不出"纸的边"。

因为最终无论是可见区域、背面区域还是折痕阴影,全部都依赖这些点。

裁剪到底在裁什么

有了这些点以后,就可以构造"当前卡片还露在外面的部分"。

比如路径 PathA

ts 复制代码
function buildPathA(): void {
  pathA.reset();
  pathA.moveTo(0, 0);
  pathA.lineTo(0, viewHeight);
  pathA.lineTo(pointC.x, pointC.y);
  pathA.quadTo(pointE.x, pointE.y, pointB.x, pointB.y);
  pathA.lineTo(pointA.x, pointA.y);
  pathA.lineTo(pointK.x, pointK.y);
  pathA.quadTo(pointH.x, pointH.y, pointJ.x, pointJ.y);
  pathA.lineTo(viewWidth, 0);
  pathA.close();
}

这条路径圈出来的,就是"当前还没被掀起来的纸面"。

然后你把截图裁进这个路径里:

ts 复制代码
canvas.save();
canvas.clipPath(pathA);
canvas.drawPixelMapMesh(pixelMap, 1, 1, verts, 0, null, 0);
canvas.restore();

背面为什么看起来像反过来了

因为它真的做了一个二维反射矩阵。

这部分是整套效果最有图形学味道的地方。

当前实现里用了一个 3x3 变换矩阵,把截图围绕折痕轴镜像:

ts 复制代码
const matrixValues: Array<number> = [0, 0, 0, 0, 0, 0, 0, 0, 1];
matrixValues[0] = -(1 - 2 * sin0 * sin0);
matrixValues[1] = 2 * sin0 * cos0;
matrixValues[3] = 2 * sin0 * cos0;
matrixValues[4] = 1 - 2 * sin0 * sin0;

const matrix = new drawing.Matrix();
matrix.reset();
matrix.setMatrix(matrixValues);
matrix.preTranslate(-pointE.x, -pointE.y);
matrix.postTranslate(pointE.x, pointE.y);
canvas.concatMatrix(matrix);

这段代码如果用人话讲,就是:

先把坐标原点挪到折痕附近,再让图片围绕折痕镜像,最后再挪回去。

这样画出来的就不是"另一张图",而是这张纸翻过去之后应有的背面。

如果你再给背面叠一层偏纸质的淡灰色:

ts 复制代码
backBrush.setColor({
  alpha: 104,
  red: 235,
  green: 241,
  blue: 247
});

整个纸感就会一下子出来。

所以你会发现,这种效果真正依赖的数学其实并不夸张:

  • 中点
  • 垂线关系
  • 直线交点
  • 矩阵反射
  • 路径裁剪

组合起来,就足够把一张卡片画得像一张被掀起的便签。


五、真正让这套效果跑顺的,不只是几何,还有工程化细节

如果前面那部分解决的是"纸怎么动",这一部分解决的就是"为什么你的纸明明应该动,但页面里就是没反应"。

这也是我这次最想强调的经验。

因为这类效果看起来像视觉特效,很多人会把排障重心放在:

  • 路径错了没
  • 矩阵错了没
  • 背面颜色是不是太深了

但实际落地时,最先拦你的往往不是绘制,而是输入链路。

1. 起点初始化不要太理想化

一开始我把起点放在 onActionStart(event) 里拿,理论上没问题。

但在实际页面结构里,这条链路并不稳定,结果就是:

  • panStartX 没被正确初始化
  • 后续方向判断失真
  • 用户感觉成了"怎么滑都没反应"

后来改成了更稳的办法:

  • onActionStart() 只负责重置状态
  • 真正的起点在首次 onActionUpdate 里反推出
ts 复制代码
private ensureGestureInitialized(event: GestureEvent): boolean {
  const finger = event.fingerList[0];
  if (!this.gestureInitialized) {
    this.gestureInitialized = true;
    this.panStartX = finger.localX - event.offsetX;
    this.panStartY = finger.localY - event.offsetY;
    this.lastPanX = this.panStartX;
    this.positionX = this.getUIContext().vp2px(this.panStartX);
    this.positionY = this.getUIContext().vp2px(this.panStartY);
    this.initDrawPosition(this.panStartY);
  }
  return true;
}

这个细节看起来小,但在复杂父容器里会稳很多。

2. 看不见的"蒙层",很多时候其实是手势竞争

当时我第一反应也怀疑是不是有遮挡层。

后来排查下来,真正的问题更像是:

  • 父容器先参与了横向手势识别
  • 当前卡片区手势优先级太低
  • 输入没有真正落到纸感卡片这层

所以最后真正让它恢复正常的,不是删掉什么可见遮罩,而是把卡片交互提升成更高优先级:

ts 复制代码
.priorityGesture(
  PanGesture({ distance: 1 })
    .onActionStart(() => {
      this.resetGestureRuntime();
    })
    .onActionUpdate((event: GestureEvent) => {
      this.handlePanUpdate(event);
    })
    .onActionEnd(() => {
      this.handlePanEnd();
    })
)
.monopolizeEvents(true)
.zIndex(30)

这里面的含义是:

  • priorityGesture:让当前卡片区先判断
  • monopolizeEvents(true):尽量独占事件
  • zIndex(30):视觉层和输入层都尽量对齐

如果你做这种纸感交互,尤其又是叠在复杂导航容器、Tabs 或浮层上面,这几个配置几乎是基础项。

3. 记得释放 PixelMap

因为每次拖动都可能重新截图,如果你不及时释放,内存会越来越难看。

ts 复制代码
private releasePixelMap(): void {
  this.pagePixelMap?.release();
  this.pagePixelMap = undefined;
  AppStorage.setOrCreate('flip_card_pixel_map', undefined);
}

4. 自动补间不要单独造另一套动画系统

这一点我很认同仿真翻页方案里的思路。

手指抬起之后,并不需要另起一套完全独立的"高级动画引擎",你只需要继续模拟手势轨迹,让绘制层继续吃坐标就够了。

ts 复制代码
private startAutoAnimation(xDiff: number, yDiff: number): void {
  this.animationTimer = setInterval(() => {
    this.positionX += xDiff;
    this.positionY += yDiff;
    AppStorage.setOrCreate('flip_card_position_x', this.positionX);
    AppStorage.setOrCreate('flip_card_position_y', this.positionY);
    this.refreshRenderNode();
  }, 16);
}

这也是这套方案很好扩展的原因。

因为"拖动中"和"自动收尾"本质上走的是同一条绘制链路。


六、最后给一套更适合复用的最小代码骨架

如果你看到这里已经准备自己做一版,我建议不要从完整文章里一段段扒代码,而是直接抓下面这几个最小骨架。

1. 三层卡片 + 自绘容器

ts 复制代码
build() {
  Stack() {
    StickyCard({ card: this.nextCard })

    StickyCard({ card: this.currentCard })
      .id('middleCard')
      .visibility(this.isMiddleCardHidden ? Visibility.None : Visibility.Visible)

    StickyCard({ card: this.previousCard })
      .translate({ x: -this.hostWidthVp })
      .id('leftCard')

    NodeContainer(this.nodeController)
      .width('100%')
      .height('100%')
      .visibility(this.isNodeVisible ? Visibility.Visible : Visibility.None)
  }
  .width('100%')
  .height(340)
  .clip(true)
  .borderRadius(32)
  .priorityGesture(
    PanGesture({ distance: 1 })
      .onActionStart(() => this.resetGestureRuntime())
      .onActionUpdate((event: GestureEvent) => this.handlePanUpdate(event))
      .onActionEnd(() => this.handlePanEnd())
  )
}

2. 手势层最小闭环

ts 复制代码
private handlePanUpdate(event: GestureEvent): void {
  if (!this.ensureGestureInitialized(event)) {
    return;
  }

  if (this.pageMoveDirection === MoveDirection.NONE &&
      !this.beginDrawingByDirection(event.offsetX)) {
    return;
  }

  const finger = event.fingerList[0];
  this.positionX = this.getUIContext().vp2px(finger.localX);
  this.positionY = this.getUIContext().vp2px(finger.localY);

  AppStorage.setOrCreate('flip_card_draw_state', DrawState.MOVING);
  AppStorage.setOrCreate('flip_card_position_x', this.positionX);
  AppStorage.setOrCreate('flip_card_position_y', this.positionY);

  this.refreshRenderNode();
}

3. NodeController 最小骨架

ts 复制代码
export class StickyNodeController extends NodeController {
  private rootNode: FrameNode | null = null;

  makeNode(uiContext: UIContext): FrameNode {
    this.rootNode = new FrameNode(uiContext);
    const renderNode = this.rootNode.getRenderNode();
    const viewWidth = AppStorage.get('flip_card_width') as number;
    const viewHeight = AppStorage.get('flip_card_height') as number;

    if (renderNode !== null) {
      renderNode.frame = {
        x: 0,
        y: 0,
        width: uiContext.px2vp(viewWidth),
        height: uiContext.px2vp(viewHeight)
      };
    }
    return this.rootNode;
  }

  addNode(node: RenderNode): void {
    this.rootNode?.getRenderNode()?.appendChild(node);
  }

  clearNodes(): void {
    this.rootNode?.getRenderNode()?.clearChildren();
  }
}

4. RenderNode 绘制入口

ts 复制代码
export class StickyRenderNode extends RenderNode {
  draw(context: DrawContext): void {
    const canvas = context.canvas;
    if (!initGeometry()) {
      return;
    }

    drawBackSide(canvas);
    drawFrontSide(canvas);
  }
}

5. 背面绘制核心

ts 复制代码
function drawBackSide(canvas: drawing.Canvas): void {
  pathC.reset();
  pathC.moveTo(pointI.x, pointI.y);
  pathC.lineTo(pointD.x, pointD.y);
  pathC.lineTo(pointB.x, pointB.y);
  pathC.lineTo(pointA.x, pointA.y);
  pathC.lineTo(pointK.x, pointK.y);
  pathC.close();

  canvas.save();
  canvas.clipPath(pathC);

  const matrix = new drawing.Matrix();
  matrix.reset();
  matrix.setMatrix(reflectValues);
  matrix.preTranslate(-pointE.x, -pointE.y);
  matrix.postTranslate(pointE.x, pointE.y);
  canvas.concatMatrix(matrix);

  canvas.drawPixelMapMesh(pixelMap, 1, 1, verts, 0, null, 0);
  canvas.restore();

  canvas.attachBrush(backBrush);
  canvas.drawPath(pathC);
  canvas.detachBrush();
}

如果你真要开始做,我建议顺序一定是:

  1. 先让截图能拿到
  2. 再让三层卡片能摆对
  3. 再让 RenderNode 能把一张图正确裁出来
  4. 最后再补背面、阴影和纸感细节

不要一开始就去追"撕裂边缘锯齿"这种视觉末梢。

因为真正决定它能不能成为一个好组件的,从来不是最后那层装饰,而是这条主链路是不是稳。


写在最后

如果让我用一句话概括这次实现,我会说:

便利贴撕下效果,本质上不是一个动画技巧,而是一种把复杂卡片内容"纸张化"的工程方法。

它最值得复用的,不是某一条贝塞尔曲线,也不是某一个阴影参数,而是这套分层思路:

  • 业务组件负责内容
  • 手势层负责坐标
  • 自绘层负责纸感

一旦这个分层成立,你会发现它不只适用于天气卡片。

它还可以继续迁移到:

  • 待办便签
  • 灵感卡片
  • 日签页
  • 推荐卡堆
  • 相册贴纸

也就是说,这不是一个一次性小花活,而是一套很适合业务继续演化的交互底盘。

相关推荐
南村群童欺我老无力.2 小时前
鸿蒙中Image图片加载失败与资源适配
华为·harmonyos
南村群童欺我老无力.2 小时前
鸿蒙开发中Scroll容器的嵌套冲突与滚动穿透
华为·harmonyos
Ulyanov2 小时前
《PySide6 GUI开发指南:QML核心与实践》 第五篇:Python与QML深度融合——数据绑定与交互
开发语言·python·qt·ui·交互·雷达电子战系统仿真
IntMainJhy2 小时前
Flutter 三方库 SecureStorage 加密存储鸿蒙化适配与实战指南(加密读写+批量操作全覆盖)
flutter·华为·harmonyos
Huanzhi_Lin12 小时前
Laya导出的鸿蒙NEXT工程目录说明
华为·harmonyos·鸿蒙·laya·deveco·devecostudio·layaair
积水成渊,蛟龙生焉12 小时前
鸿蒙手势处理篇(滑动冲突、基础手势、组合手势)
华为·arkts·鸿蒙·滑动冲突·手势冲突·基础手势·组合手势
纯爱掌门人18 小时前
聊聊 HarmonyOS 上的应用内通知授权弹窗
前端·harmonyos·arkts
不喝水就会渴19 小时前
从基础到实战:鸿蒙 ArkUI 属性动画开发指南
华为·交互·动画·harmonyos
代码论斤卖20 小时前
OpenHarmony teecd频繁崩溃问题分析
linux·harmonyos