HarmonyOS 纸感交互实战:把天气卡片做成便利贴撕下效果
-
- 一、先说场景:为什么一个天气卡片,值得做成"便利贴撕下"
- 二、核心思路:不是"拉扯组件",而是"先把组件拍成一张纸,再去折这张纸"
- 三、从手指到纸张:这条链路里真正发生了什么
-
- [1. 先判断这次拖动的"意图"](#1. 先判断这次拖动的“意图”)
- [2. 决定截图哪一层](#2. 决定截图哪一层)
- [3. 手势层不负责画图,只负责"喂坐标"](#3. 手势层不负责画图,只负责“喂坐标”)
- [4. 绘制层每次都根据新坐标重算"纸的形状"](#4. 绘制层每次都根据新坐标重算“纸的形状”)
- 四、便利贴为什么能被"掀起来":这背后其实是一点很朴素的几何
- 五、真正让这套效果跑顺的,不只是几何,还有工程化细节
-
- [1. 起点初始化不要太理想化](#1. 起点初始化不要太理想化)
- [2. 看不见的"蒙层",很多时候其实是手势竞争](#2. 看不见的“蒙层”,很多时候其实是手势竞争)
- [3. 记得释放 `PixelMap`](#3. 记得释放
PixelMap) - [4. 自动补间不要单独造另一套动画系统](#4. 自动补间不要单独造另一套动画系统)
- 六、最后给一套更适合复用的最小代码骨架
-
- [1. 三层卡片 + 自绘容器](#1. 三层卡片 + 自绘容器)
- [2. 手势层最小闭环](#2. 手势层最小闭环)
- [3. `NodeController` 最小骨架](#3.
NodeController最小骨架) - [4. `RenderNode` 绘制入口](#4.
RenderNode绘制入口) - [5. 背面绘制核心](#5. 背面绘制核心)
- 写在最后
很多首页中间卡片都做得很"完整",但不够"有手感"。
这次我拿一个天气场景里的中部卡片区做实验,没有继续堆渐变和阴影,而是把它改造成了一种更像真实纸张的交互:手指拖动时,卡片像一张贴在桌面上的便利贴,被一点点掀起、撕下、露出下一张。
这篇文章不会只讲视觉结果,而是把完整技术链路拆开:为什么我没有直接做组件形变、为什么最后还是回到"组件截图 + 自绘"这条路、从电子书翻页思路迁移到便利贴撕下时哪些部分可以复用、这套效果背后到底用了哪些几何关系,以及在 HarmonyOS 里真正让它跑顺时踩过的几个典型坑。

一、先说场景:为什么一个天气卡片,值得做成"便利贴撕下"
做首页中间卡片时,最常见的写法其实很标准:
- 一张大卡片
- 上面放当前温度、趋势、生活提示
- 配一点圆角、阴影、玻璃感
它当然没问题,甚至大多数时候已经足够"好看"。
但问题也恰恰在这里。
它很完整,却不够有触感。
如果整个页面的视觉气质更轻一点、更像桌面陈列,而不是标准信息面板,那这张卡片继续保持纯静态展示,用户会很容易只把它当成一个"看完就划走"的区块。
我当时想象的不是"再做一个轮播",也不是"加一点无关痛痒的微动效",而是一个更生活化的交互画面:
早上打开天气页,看到桌面中央压着几张便签。
第一张写着"今天 26°C,空气不错"。
你手指往左一拖,这张便签从右下角被掀起一点,背面露出来,底下第二张"24 小时趋势"慢慢显现;再往前拖,第一张像被顺手撕掉一样离开舞台,第二张稳稳接上。
这种感觉和普通卡片切换最大的区别,不是"更炫",而是更像在操作一个物体。
而一旦要把"物体感"做出来,事情就不再只是动画问题了。
它会变成一个很工程的命题:
- 复杂卡片内容怎么参与变形?
- 拖动的是组件,还是组件的截图?
- 卡片背面怎么画?
- 掀起区域怎么裁?
- 下层卡片什么时候露出来才自然?
- 手势和父容器手势冲突时,谁说了算?
这也是为什么我最后没有走纯组件动画路线,而是借用了另一类非常成熟的思路:仿真翻页。
二、核心思路:不是"拉扯组件",而是"先把组件拍成一张纸,再去折这张纸"
这次实现真正的转折点,不是我写了某个神奇动画,而是我换了一个思考方式。
一开始很容易想到的是这些手段:
- 拖动时让卡片跟手位移
- 叠一点
rotate - 再叠一点
scale - 最后补一层透明度和投影
这种做法可以让卡片"动起来",但只要你想把它做得更像"纸被掀起",马上就会碰到几个很现实的问题:

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

组件负责摆内容,自绘层负责模拟纸。
也就是:
- 页面里先摆好三层静态卡片
- 手势开始后,把当前卡片或上一张卡片截成
PixelMap - 把这张截图交给
NodeContainer + RenderNode - 后续拖动过程不再直接操作业务组件,而是操作这张位图
这套结构很像一句很形象的话:
先把页面拍扁成一张纸,再去掀这张纸。
先看一眼最核心的截图动作:
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:固定参考点,右上角或右下角(你从下翻还是从上往下翻)G:A和F的中点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}
接着再用垂线关系求出 E 和 H。
当前实现里用的是这种形式:
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 / K、C / 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();
}
如果你真要开始做,我建议顺序一定是:
- 先让截图能拿到
- 再让三层卡片能摆对
- 再让
RenderNode能把一张图正确裁出来 - 最后再补背面、阴影和纸感细节
不要一开始就去追"撕裂边缘锯齿"这种视觉末梢。
因为真正决定它能不能成为一个好组件的,从来不是最后那层装饰,而是这条主链路是不是稳。
写在最后
如果让我用一句话概括这次实现,我会说:
便利贴撕下效果,本质上不是一个动画技巧,而是一种把复杂卡片内容"纸张化"的工程方法。
它最值得复用的,不是某一条贝塞尔曲线,也不是某一个阴影参数,而是这套分层思路:
- 业务组件负责内容
- 手势层负责坐标
- 自绘层负责纸感
一旦这个分层成立,你会发现它不只适用于天气卡片。
它还可以继续迁移到:
- 待办便签
- 灵感卡片
- 日签页
- 推荐卡堆
- 相册贴纸
也就是说,这不是一个一次性小花活,而是一套很适合业务继续演化的交互底盘。