使用motion实现小宇宙节目广场的效果

本文可以在 我的博客 查看交互效果和完整代码

小宇宙app节目广场有一个效果,感觉特别好,想用motion复刻一下:

拆解一下,需要实现这几个效果:

  1. 拖动效果
  2. 超出范围会复位
  3. 并且有弹性效果
  4. 拖动具有惯性
  5. 行列具有吸附效果
  6. 距离中间越近 卡片越大

接下来一点一点实现:

实现静态页面

定义好尺寸常量,卡片尺寸,间隔,视图大小(模拟手机)

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 });
    };

实现惯性效果

在移动设备中,当我们用一个比较快的速度拖动元素的时候,松开手他会以一定的惯性继续移动,我们接下来要实现这个效果,但是要注意一下几点:

  1. 拖拽阶段,rubber软边界(越界有阻力效果,已实现)
  2. 松手惯性阶段:当有一定速度的时候,给元素惯性,但是也要考虑越界的情况,越界的话 要使用spring回弹
  3. 低速松手:直接回弹,无惯性。
  4. 新一轮拖拽开始:停止惯性,确保跟手。就像你抓住了空中飞过来的飞盘

松手惯性阶段要考虑:惯性、越界早停以及回弹

惯性这个好实现,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);
      }

有了归位函数,我们需要明确调用时机:

  1. 触发低速阈值
  2. 处罚早停阈值
  3. 惯性兜底,等动画结束后,防止没归位,在调用一次

实现缩放效果

想实现卡片的位置和尺寸有一种函数关系,最好的方法就是使用 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>
      );
    }

需要注意的点

  • 当动画出现卡顿/打架的情况,看看对于动画变量是不是出现了双写的情况
相关推荐
知花实央l2 小时前
【Web应用实战】 文件上传漏洞实战:Low/Medium/High三级绕过(一句话木马拿webshell全流程)
前端·学习·网络安全·安全架构
华仔啊2 小时前
JavaScript + Web Audio API 打造炫酷音乐可视化效果,让你的网页跟随音乐跳起来
前端·javascript
鸡吃丸子2 小时前
SEO入门
前端
檀越剑指大厂2 小时前
【Nginx系列】Tengine:基于 Nginx 的高性能 Web 服务器与反向代理服务器
服务器·前端·nginx
是你的小橘呀3 小时前
深入理解 JavaScript 预编译:从原理到实践
前端·javascript
uhakadotcom3 小时前
在使用cloudflare workers时,假如有几十个请求,如何去控制并发?
前端·面试·架构
风止何安啊3 小时前
栈与堆的精妙舞剧:JavaScript 数据类型深度解析
前端·javascript
用户47949283569153 小时前
Chrome DevTools MCP:让 AI 助手直接操作浏览器开发工具
前端·javascript·chrome
Rysxt_3 小时前
Vuex 教程 从入门到实践
前端·javascript·vue.js