本文可以在 我的博客 查看交互效果和完整代码
小宇宙app节目广场有一个效果,感觉特别好,想用motion复刻一下:

拆解一下,需要实现这几个效果:
- 拖动效果
- 超出范围会复位
- 并且有弹性效果
- 拖动具有惯性
- 行列具有吸附效果
- 距离中间越近 卡片越大
接下来一点一点实现:
实现静态页面
定义好尺寸常量,卡片尺寸,间隔,视图大小(模拟手机)
typescript
import { motion } from "motion/react";
export default function Plaza() {
const COLS = 5, ROWS = 5, CARD = 100, GAP = 10, PAD = 20;
const viewportW = 300, viewportH = 500;
return (
<motion.div
style={{
width: viewportW,
height: viewportH,
border: "2px solid red",
overflow: "hidden",
position: "relative",
touchAction: "none",
cursor: "grab",
userSelect: "none",
}}
>
<motion.div
style={{
position: "absolute",
left: 0, top: 0,
display: "grid",
gridTemplateColumns: `repeat(${COLS}, ${CARD}px)`,
gridAutoRows: `${CARD}px`,
gap: GAP,
padding: PAD,
background: "rgba(0,0,0,.5)",
}}
>
{Array.from({ length: COLS * ROWS }).map((_, i) => (
<div key={i} style={{
width: "100%", height: "100%", background: "green",
borderRadius: 8, color: "#fff", display: "flex",
alignItems: "center", justifyContent: "center"
}}>
{i + 1}
</div>
))}
</motion.div>
</motion.div>
);
}
实现拖动
思路就是给视图容器绑定一个pan事件,拖动多少就给里面元素transform多少,这样就实现了跟手效果
typescript
import { motion, useMotionValue, animate } from "motion/react";
export default function Plaza() {
const x = useMotionValue(0);
const y = useMotionValue(0);
// 你当前示例是 5x5、卡片 100、gap 10、padding 20
const COLS = 5, ROWS = 5, CARD = 100, GAP = 10, PAD = 20;
const viewportW = 300, viewportH = 500;
const onPan = (_: any, info: { delta: { x: number; y: number } }) => {
// 先按正常增量计算
const nextX = x.get() + info.delta.x;
const nextY = y.get() + info.delta.y;
x.set(nextX);
y.set(nextY);
};
const onPanEnd = () => {
};
return (
<motion.div
onPan={onPan}
onPanEnd={onPanEnd}
style={{
width: viewportW,
height: viewportH,
border: "2px solid red",
overflow: "hidden",
position: "relative",
touchAction: "none",
cursor: "grab",
userSelect: "none",
}}
>
<motion.div
style={{
x, y,
position: "absolute",
left: 0, top: 0,
display: "grid",
gridTemplateColumns: `repeat(${COLS}, ${CARD}px)`,
gridAutoRows: `${CARD}px`,
gap: GAP,
padding: PAD,
willChange: "transform",
background: "rgba(0,0,0,.5)",
}}
>
{Array.from({ length: COLS * ROWS }).map((_, i) => (
<div key={i} style={{
width: "100%", height: "100%", background: "green",
borderRadius: 8, color: "#fff", display: "flex",
alignItems: "center", justifyContent: "center"
}}>
{i + 1}
</div>
))}
</motion.div>
</motion.div>
);
}
首先声明2个变量,表示画布2个方向的偏移
typescript
const x = useMotionValue(0);
const y = useMotionValue(0);
给内容区域绑定这个偏移,由于motion是利用transform实现移动变换的,增加一个willChange: transform,告诉浏览器要针对transform事件进行优化
typescript
<motion.div
style={{
x, y,
position: "absolute",
left: 0, top: 0,
display: "grid",
gridTemplateColumns: `repeat(${COLS}, ${CARD}px)`,
gridAutoRows: `${CARD}px`,
gap: GAP,
padding: PAD,
willChange: "transform",
background: "rgba(0,0,0,.5)",
}}
>
然后拖动时更新一下变量x y
typescript
const onPan = (_: any, info: { delta: { x: number; y: number } }) => {
// 先按正常增量计算
const nextX = x.get() + info.delta.x;
const nextY = y.get() + info.delta.y;
x.set(nextX);
y.set(nextY);
};
实现超出范围之后 回到原位
上面例子中可以看到,黑色部分的内容区域被拖出了屏幕范围,这样显然不合理,我们想实现,拖出之后 他能自己反弹回来
那么第一步,我们首先要得到内容区域的宽高 才可以判断内容区域是否超出了边界,
尺寸计算:也就是卡片尺寸+卡片间隔+内容区padding
然后考虑2种情况,一种是内容区域比视口小,那么边界其实就是视口,最小的x,y都是0
一种是内容区域比视口大, 当前画布能最多往左移动 contentW - viewPortW,画个图好理解
最大x和y都是0 这个好理解,因为内容区域和视口都是左上角对齐,此时不允许在往右了,再移动就出现空白区域了
typescript
const viewportW = 300, viewportH = 500;
const contentW = COLS * CARD + (COLS - 1) * GAP + PAD * 2;
const contentH = ROWS * CARD + (ROWS - 1) * GAP + PAD * 2;
const minX = Math.min(0, viewportW - contentW);
const minY = Math.min(0, viewportH - contentH);
const maxX = 0, maxY = 0;
当x和y超出返回的时候,我们希望他回到合理的位置,也就是离他最近的边界。举个例子,x最小只能是-10,现在往左移动太多了,到了-30,那么我们希望他可以回到-10的位置
这个函数比较简单,如果在min和max之间就取value,如果超出了哪个边界 就以那个边界为准
typescript
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
接下来就要实现回弹了,在很多应用上有下拉刷新的动画,你会发现你可以一直往下拉,但是随着你拉的越多,页面下滑的反而越少,就像是在拉一个弹簧一样,我们就要实现这样的效果。
原来鼠标移动的距离跟内容区域移动距离是一样的,所以才会有跟手的效果。但现在由于有弹簧这样的效果,偏移的计算就会有变化
实现一个偏移计算函数,想象一样 你收到弹簧的力取决于两个参数:1. 弹簧本身弹性 2. 弹簧被拉长的长度
那么很容易想到这个偏移计算函数: 移动距离跟形变和弹性都成反比
其中constant是一个常数 可以理解为弹性,1是为了防止分母为0
typescript
function rubber(delta: number, overflow: number, constant = 0.15) {
return delta / (1 + overflow * constant);
}
接下来改造一下onPan拖动的回调函数,原来是拖动多少 就给内容区移动多少,现在则是要加上回弹的效果
先计算一下每个方向的溢出范围,可以理解为弹簧被拉动的长度,如果没超出范围 就是0
typescript
const overflowLeft = Math.max(0, minX - nextX);
const overflowRight = Math.max(0, nextX - maxX);
const overflowTop = Math.max(0, minY - nextY);
const overflowBottom = Math.max(0, nextY - maxY);
然后利用刚刚的偏移计算函数,更新位置,这里x.get()是被拖动之前的距离,delta.x是鼠标拖动的距离,通过rubber函数得到被压缩的距离,然后累加到x上
typescript
if (overflowLeft > 0 || overflowRight > 0) {
const overflow = Math.max(overflowLeft, overflowRight);
nextX = x.get() + rubber(info.delta.x, overflow);
}
if (overflowTop > 0 || overflowBottom > 0) {
const overflow = Math.max(overflowTop, overflowBottom);
nextY = y.get() + rubber(info.delta.y, overflow);
}
x.set(nextX); y.set(nextY);
再想想下拉刷新的动画,我们下拉结束之后 内容会回到原位,这个我们也实现一下,也比较简单,就是在pan事件结束之后,把x和y通过clamp函数移动到边界,但是加上一个类似弹簧的动画,这个可以通过motion的animate函数来实现(它本身其实是一个插值)
typescript
const onPanEnd = () => {
const targetX = clamp(x.get(), minX, maxX);
const targetY = clamp(y.get(), minY, maxY);
animate(x, targetX, { type: "spring", stiffness: 500, damping: 40 });
animate(y, targetY, { type: "spring", stiffness: 500, damping: 40 });
};
实现惯性效果
在移动设备中,当我们用一个比较快的速度拖动元素的时候,松开手他会以一定的惯性继续移动,我们接下来要实现这个效果,但是要注意一下几点:
- 拖拽阶段,rubber软边界(越界有阻力效果,已实现)
- 松手惯性阶段:当有一定速度的时候,给元素惯性,但是也要考虑越界的情况,越界的话 要使用spring回弹
- 低速松手:直接回弹,无惯性。
- 新一轮拖拽开始:停止惯性,确保跟手。就像你抓住了空中飞过来的飞盘
松手惯性阶段要考虑:惯性、越界早停以及回弹
惯性这个好实现,motion提供的onPanEnd里提供了速度变量,我们可以偏移量=速度x一个常量,这样实现速度越大 偏移越大,从而实现惯性
然后考虑一下低速情况下松手,此时速度比较小,直接用上面的animate回弹就行
typescript
const SPEED_MIN = 60; // 低速阈值(px/s),低于它直接回弹
const onPanEnd = (_: any, info: { velocity: { x: number; y: number } }) => {
const vx = info.velocity.x;
const vy = info.velocity.y;
// 低速松手:直接回弹,不进惯性
if (Math.hypot(vx, vy) < SPEED_MIN) {
animate(x, clamp(x.get(), minX, maxX), SPRING);
animate(y, clamp(y.get(), minY, maxY), SPRING);
return;
}
const targetX = x.get() + vx * 0.5;
const targetY = y.get() + vy * 0.5;
....
}
然后我们实现一个早停,这个效果有点像一个物体受到摩擦慢慢停下,在motion里它叫做inertia,他有两个参数:
- timeConstant用来控制速度时间衰减的所需时间,越小速度减少越快
- power 控制初速度,越大 元素移动的越远
下面函数,使用animate触发函数,返回值复制给一个ref,方便我们随时用停止动画
onUpdate回调,在每次位置变化都会调用,这里面我们要判断是否越界,如果不越界 不做处理
如果越界了,我们给他一个回弹的效果,这个实现起来也很简单,先计算越界的偏移量,越界越多,他实际移动距离越小,直到距离特别小的时候,使用回弹。
typescript
const SPEED_MIN = 60; // 低速阈值(px),低于它直接回弹
const EPS_DELTA = 0.25; // 越界早停阈值:本帧位移小于它就视为已停
// 动画控制句柄(用于 stop)
const axCtrlRef = useRef<ReturnType<typeof animate> | null>(null);
const ayCtrlRef = useRef<ReturnType<typeof animate> | null>(null);
const INERTIA = { type: "inertia" as const, power: 0.8, timeConstant: 350 };
// ------ X 轴惯性
axCtrlRef.current = animate(x, targetX, {
...INERTIA,
onUpdate: (v) => {
const left = Math.max(0, minX - v);
const right = Math.max(0, v - maxX);
if (left > 0 || right > 0) {
const overflow = Math.max(left, right);
const last = x.get();
const delta = v - last;
const next = last + rubber(delta, overflow);
// 越界且步幅极小 → 立刻停止惯性并回弹
if (Math.abs(next - last) < EPS_DELTA) {
axCtrlRef.current?.stop();
animate(x, clamp(next, minX, maxX), SPRING);
} else {
x.set(next);
}
} else {
x.set(v);
}
},
onComplete: () => {
// 兜底:即便没触发早停,也确保最终归位
animate(x, clamp(x.get(), minX, maxX), SPRING);
},
});
// ------ Y 轴惯性
ayCtrlRef.current = animate(y, targetY, {
... 同上
};
实现行列吸附效果
上面我们实现了内容区域越界之后 自动clamp,吸附到视口边界的情况,现在我们要实现吸附到网格的位置。思路很简单:松手的时候,计算视口中心点距离所有网格中心点的距离,找到最小的那个,然后计算偏移,回弹
首先先改一下行列数目都是10,让横向纵向都能移动
typescript
const COLS = 10, ROWS = 10, CARD = 100, GAP = 10, PAD = 20;
定义一些常量,步长是卡片size+gap,得到第一个网格的中心坐标,方便用一个行列号得到所有网格的坐标
typescript
// 网格步长 & 首格中心(画布坐标)
const STEP_X = CARD + GAP;
const STEP_Y = CARD + GAP;
const BASE_X = PAD + CARD / 2; // 第1格中心x
const BASE_Y = PAD + CARD / 2; // 第1格中心y
我们计算距离的时候,是假设画布没变的(如果计算每个网格偏移很麻烦),所以视口位置是在变化的
typescript
function snapToNearestBySearch(
xNow: number,
yNow: number,
viewportW: number,
viewportH: number,
cols: number,
rows: number,
minX: number,
maxX: number,
minY: number,
maxY: number
) {
const Cx = viewportW / 2;
const Cy = viewportH / 2;
// 视口中心在"画布坐标系"下的位置
const centerInCanvasX = Cx - xNow;
const centerInCanvasY = Cy - yNow;
// 穷举所有格心,找最近
let bestCol = 0,
bestRow = 0,
bestD2 = Number.POSITIVE_INFINITY;
for (let r = 0; r < rows; r++) {
const cy = BASE_Y + r * STEP_Y;
for (let c = 0; c < cols; c++) {
const cx = BASE_X + c * STEP_X;
const dx = cx - centerInCanvasX;
const dy = cy - centerInCanvasY;
const d2 = dx * dx + dy * dy; // 用平方距离避免开方
if (d2 < bestD2) {
bestD2 = d2;
bestCol = c;
bestRow = r;
}
}
}
// 最近格心(画布坐标)
const snappedCx = BASE_X + bestCol * STEP_X;
const snappedCy = BASE_Y + bestRow * STEP_Y;
// 让该格心对齐到视口中心 → 需要的画布的偏移
let targetX = Cx - snappedCx;
let targetY = Cy - snappedCy;
// 边界clamp
targetX = Math.min(Math.max(targetX, minX), maxX);
targetY = Math.min(Math.max(targetY, minY), maxY);
return { targetX, targetY, bestCol, bestRow };
}
然后写一个归位函数,同时移动x和y,防止有先后顺序 不太自然
function settleToNearest(xNow: number, yNow: number) {
if (settledRef.current) return;
settledRef.current = true;
// 停掉两轴惯性,防止抢位置
axCtrlRef.current?.stop();
ayCtrlRef.current?.stop();
const { targetX, targetY } = snapToNearestBySearch(
xNow, yNow, viewportW, viewportH, COLS, ROWS, minX, maxX, minY, maxY
);
// 同时开两条 spring(同一时刻发出)
animate(x, targetX, SPRING);
animate(y, targetY, SPRING);
}
有了归位函数,我们需要明确调用时机:
- 触发低速阈值
- 处罚早停阈值
- 惯性兜底,等动画结束后,防止没归位,在调用一次
实现缩放效果
想实现卡片的位置和尺寸有一种函数关系,最好的方法就是使用 useTransform 实现派生动画值,好处是动画变量更新时,不会触发React重渲染,只会在样式层更新,因此性能比较好
我们想实现卡片中心距离视口中心越近,卡片越大。当然这里是有个上限 不可能无限大和无限小,我们需要将距离归一化为0~1的一个数字,一个比较简单的方法就是直接把距离处以一个最大距离,这里规定最大距离为视口对角线一半
typescript
const centerInCanvas = useTransform([x, y], ([tx, ty]) => ({
cx: viewportW / 2 - tx,
cy: viewportH / 2 - ty,
}));
const t = useTransform(centerInCanvas, ({ cx, cy }) => {
const dx = cardCX - cx;
const dy = cardCY - cy;
const dist = Math.hypot(dx, dy);
const maxDist = Math.hypot(viewportW / 2, viewportH / 2); // 对角的一半
return Math.min(dist / maxDist, 1); // 0=中心, 1=最远
});
这个t越小,距离中心越近,那么尺寸越大,所以我们用一个公式,scale = min + (max-min)*(1-t)
这是一个比较常见的归一化公式,通过线性插值缩放,这里如果是(1-t)就是反比,t就是正比
然后我们也希望,尺寸变化也是有一个过渡的,不能突变或者太线性,因此再用一个useSpring增加弹簧效果
typescript
const Card = ({
col,
row,
x,
y,
viewportW,
viewportH,
BASE_X,
BASE_Y,
STEP_X,
STEP_Y,
CARD,
COLS,
minScale = 0.85,
maxScale = 1.20,
}: CardProps) => {
// 这张卡片在"画布坐标系"的中心点(不随位移而变)
const cardCX = BASE_X + col * STEP_X;
const cardCY = BASE_Y + row * STEP_Y;
// 1) 由 [x,y] 派生"视口中心在画布坐标中的位置"
const centerInCanvas = useTransform([x, y], ([tx, ty]) => ({
cx: viewportW / 2 - tx,
cy: viewportH / 2 - ty,
}));
// 2) 距离 → 归一化到 [0,1]
const t = useTransform(centerInCanvas, ({ cx, cy }) => {
const dx = cardCX - cx;
const dy = cardCY - cy;
const dist = Math.hypot(dx, dy);
const maxDist = Math.hypot(viewportW / 2, viewportH / 2); // 对角的一半
return Math.min(dist / maxDist, 1); // 0=中心, 1=最远
});
// 3) t 越小越靠近中心:scale 从 minScale → maxScale
const scaleRaw = useTransform(t, (v) => minScale + (maxScale - minScale) * (1 - v));
const scale = useSpring(scaleRaw, { stiffness: 320, damping: 38, mass: 0.25 });
return (
<motion.div
className="card"
style={{
width: CARD,
height: CARD,
transformOrigin: "center",
scale,
background: "green",
borderRadius: 8,
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
willChange: "transform",
}}
>
{row * COLS + col + 1}
</motion.div>
);
}
需要注意的点
- 当动画出现卡顿/打架的情况,看看对于动画变量是不是出现了双写的情况