18
头图

最近做了一个移动端活动页的需求,大概就是 diy 一个页面。用户可以对图片进行拖动、缩放、旋转,来达到 diy 的目的。
我采用了 translate、scale、rotate 来实现和用户的交互。开发过程中,涉及到了对元素的拖动、缩放、旋转等。

gif图

本文将详细介绍在不使用任何第三方库的情况下,如何实现这些功能。最终的效果 demo,可以参考上面的 gif 图。

扫描二维码体验

二维码体验.jpg

需求分析

整个需求的大致流程是:

用户点击按钮上传图片,图片内容审核(推荐 好未来 AILab ),图片显示在页面上。
用户在对元素进行拖动、缩放、旋转等操作。
用户可以生成 操作之后的 图片。

实现拖动、缩放、旋转等交互,最核心的两个点就是

1.用户触摸图片时,单指、双指操作记录 2.用户移动图片时,根据手指的路径,控制元素的运动

拖拽、缩放、旋转对应的 JS 事件

手指按下会触发 onTouchStart 事件 onTouchStart={(e) => touchstartCallback(e)}
手指移动会触发 onTouchMove 事件 onTouchMove={(e) => touchmoveCallback(e)}
手指松开会触发 onTouchEnd 事件 onTouchEnd={(e) => touchendCallback(e)}

拖拽、缩放、旋转对应的变量 和 Dom

    const [stv, setStv] = useState({
    offsetX: 0, //图片坐标 x
    offsetY: 0, //图片坐标 y
    zoom: false, //是否缩放状态
    distance: 0, //两指距离
    scale: 1, //缩放倍数
    rotate: 0, //旋转角度,
    offsetLeftX: 0,
    offsetLeftY: 0,
    });

    const [originImg, setOriginImg] = useState({
    url: "",
    width: 100,
    height: 300,
    });
<div
  className="img"
  onTouchStart={(e) => touchstartCallback(e)}
  onTouchMove={(e) => touchmoveCallback(e)}
  onTouchEnd={(e) => touchendCallback(e)}
>

    <img
      style={{
        transform: `translate(${stv.offsetX}px,${stv.offsetY}px) scale(${stv.scale}) rotate(${stv.rotate}deg)`,
        width: `${originImg.width}px`,
        height: `${originImg.height}px`,
        position: "absolute",
      }}
      src={originImg.url}
    ></img>

</div>

拖拽

拖拽.jpg

拖拽其实是通过获取移动的距离来实现的,
即计算移动前的位置的坐标(x,y)与移动中的位置的坐标(x,y)差值
当手指按下或移动时,都可以获取到当前手指的位置,即移动前的位置与移动中的位置

 let startX: number, startY: number;

 /**触摸*/
 const touchstartCallback = (e: React.TouchEvent<HTMLDivElement>) => {
  if (e.touches.length === 1) {
    const { clientX, clientY } = e.touches[0];
    startX = clientX;
    startY = clientY;
  }
};

/**触摸移动中*/
const touchmoveCallback = (e: React.TouchEvent<HTMLDivElement>) => {
  /** 单指移动*/
  if (e.touches.length === 1) {
    /** 缩放状态,不处理单指*/
    if (stv.zoom) {
      return;
    }
    const { clientX, clientY } = e.touches[0];
    const offsetX = clientX - startX;
    const offsetY = clientY - startY;
    startX = clientX;
    startY = clientY;

    const stv2 = { ...stv } as {
      offsetX: number;
      offsetY: number;
      zoom: boolean;
      distance: number;
      scale: number;
      rotate: number;
      offsetLeftX: number;
      offsetLeftY: number;
    };

    stv2.offsetX += offsetX;
    stv2.offsetY += offsetY;
    stv2.offsetLeftX = -stv2.offsetX;
    stv2.offsetLeftY = -stv2.offsetLeftY;
    setStv({ ...stv2 });
  }
};

/** 手指松开 触摸结束*/

const touchendCallback = (e: React.TouchEvent<HTMLDivElement>) => {
  if (e.touches.length === 0) {
    const obj = {} as {
      zoom: boolean;
    };
     /** 重置缩放状态 缩放状态用来控制缩放 缩放代码讲解中有实现*/
    obj["zoom"] = false;
    setStv({
      ...stv,
      ...obj,
    });
  }
};

缩放

缩放.jpg

以触摸的两点的连线的中心点为变换中心点,做缩放变换
当前两根手指之间的距离除去上一次两根手指的距离就是这一次的缩放量
同样也是在 start 的时候记录两指之间的距离,距离用勾股定理就可以算出来,在 move 的时候计算出当前两指的距离,当前距离减去上次记录的距离

let twoPoint = {
  x1: 0,
  y1: 0,
  x2: 0,
  y2: 0,
};

 const touchstartCallback = (e: React.TouchEvent<HTMLDivElement>) => {

  if (e.touches.length !== 1) {
    const xMove = e.touches[1].clientX - e.touches[0].clientX;
    const yMove = e.touches[1].clientY - e.touches[0].clientY;
    const distance = Math.sqrt(xMove * xMove + yMove * yMove);
    twoPoint.x1 = e.touches[0].pageX * 2;
    twoPoint.y1 = e.touches[0].pageY * 2;
    twoPoint.x2 = e.touches[1].pageX * 2;
    twoPoint.y2 = e.touches[1].pageY * 2;
    const obj = {} as {
      distance: number;
      zoom: boolean;
    };
    obj["distance"] = distance;
    obj["zoom"] = true; //缩放状态
    setStv({
      ...stv,
      ...obj,
    });
  }

};

const touchMove = (e: React.TouchEvent<HTMLDivElement>) => {

  if (e.touches.length === 2) {
      //双指缩放
      const xMove = e.touches[1].clientX - e.touches[0].clientX;
      const yMove = e.touches[1].clientY - e.touches[0].clientY;
      const distance = Math.sqrt(xMove * xMove + yMove * yMove);

      const distanceDiff = distance - stv.distance;
      const newScale = stv.scale + 0.005 * distanceDiff;
      if (newScale < 0.2 || newScale > 2.5) {
        return;
      }
      const obj = {} as {
        distance: number;
        scale: number;
      };
      obj["distance"] = distance;
      obj["scale"] = newScale;

      setStv({ ...stv, ...obj });
  }

}

旋转

旋转中用到知识点 点乘 、叉乘

点乘用来计算旋转的角度,叉乘用来计算旋转的方向

向量(也称为矢量),指具有大小和方向的量。它可以形象化地表示为带箭头的线段。
箭头所指:代表向量的方向;线段长度:代表向量的大小。

向量1.jpg

向量的点乘

向量2.jpg

点乘有什么用呢,我们有:A B = |A||B|Cos(θ)
θ 是向量 A 和向量 B 见的夹角。这里|A|我们称为向量 A 的模(norm),也就是 A 的长度, 在二维空间中就是|A| = sqrt(x2+y2)。
这样我们就和容易计算两条线的夹角: Cos(θ) = AB /(|A||B|)。

用一下反余弦函数 acos(),返回值的单位为弧度,弧度(rad)换算成角度(deg):x=∠A*(180/π)

向量的叉乘

向量3.jpg

叉乘的运算结果是一个向量而不是一个标量, 向量 C 的方向与 A,B 所在的平面垂直,方向用“右手法则”判断。

判断方法如下:
右手手掌张开,四指并拢,大拇指垂直于四指指向的方向;
伸出右手,四指弯曲,四指与 A 旋转到 B 方向一致,那么大拇指指向为 C 向量的方向。

旋转、缩放、拖拽 完整的 touchstartCallback 代码

  const touchstartCallback = (e: React.TouchEvent<HTMLDivElement>) => {
    if (e.touches.length === 1) {
      const { clientX, clientY } = e.touches[0];
      startX = clientX;
      startY = clientY;
    } else {
      const xMove = e.touches[1].clientX - e.touches[0].clientX;
      const yMove = e.touches[1].clientY - e.touches[0].clientY;
      const distance = Math.sqrt(xMove * xMove + yMove * yMove);
      twoPoint.x1 = e.touches[0].pageX * 2;
      twoPoint.y1 = e.touches[0].pageY * 2;
      twoPoint.x2 = e.touches[1].pageX * 2;
      twoPoint.y2 = e.touches[1].pageY * 2;
      const obj = {} as {
        distance: number;
        zoom: boolean;
      };
      obj["distance"] = distance;
      obj["zoom"] = true; //缩放状态
      setStv({
        ...stv,
        ...obj,
      });
    }
  };

旋转 touchMove 代码

/** touchMove计算旋转代码 */

 //计算叉乘
const calculateVC = (vector1: { x: number; y: number }, vector2: { x: number; y: number }) => {
  return vector1.x * vector2.y - vector2.x * vector1.y > 0 ? 1 : -1;
};

//计算点乘
const calculateVM = (vector1: { x: number; y: number }, vector2: { x: number; y: number }) => {
  return (
    (vector1.x * vector2.x + vector1.y * vector2.y) /
    (Math.sqrt(vector1.x * vector1.x + vector1.y * vector1.y) *
      Math.sqrt(vector2.x * vector2.x + vector2.y * vector2.y))
  );
};

 const vector = function(open: OpenAttribute, x1: number, y1: number, x2: number, y2: number) {
    const open2 = open;
    open2.x = x2 - x1;
    open2.y = y2 - y1;
    return open2;
  };

 const touchMove = (e: React.TouchEvent<HTMLDivElement>) => {
   if (e.touches.length === 2) {
     //计算旋转
    const preTwoPoint = JSON.parse(JSON.stringify(twoPoint));
    twoPoint.x1 = e.touches[0].pageX * 2;
    twoPoint.y1 = e.touches[0].pageY * 2;
    twoPoint.x2 = e.touches[1].pageX * 2;

   const vector1 = vector(
      {x:0,y:0},
      preTwoPoint.x1,
      preTwoPoint.y1,
      preTwoPoint.x2,
      preTwoPoint.y2,
    );
    const vector2 =  vector({x:0,y:0},twoPoint.x1, twoPoint.y1, twoPoint.x2, twoPoint.y2);
    const cos = calculateVM(vector1, vector2);
    const angle = (Math.acos(cos) * 180) / Math.PI;

    const direction = calculateVC(vector1, vector2);
    const _allDeg = direction * angle;

    if (Math.abs(_allDeg) > 1) {
      const obj = {} as {
        rotate: number;
      };
      obj["rotate"] = stv.rotate + _allDeg;

      setStv({ ...stv, ...obj });
   }
 }

以上是我对 旋转 拖拽 缩放技术点的总结,如有疑问欢迎在评论区一起讨论

完整 TS less 文件 下载

插播一条招聘信息,LeapFE 招聘前端工程师

如果你对 用户体验、交互操作流程及用户需求 "有一些" 追求
如果你对 web 、小程序 、Electron 技术 "有一些" 认识
如果你 很擅长前端新技术的学习和分享

👏 欢迎加入好未来,欢迎加入 LeapFE 一起做一些有意思的事情

参考文献

https://segmentfault.com/a/11...
https://juejin.cn/post/684490...
https://juejin.cn/post/691159...


LeapFE
1.1k 声望2.3k 粉丝

字节内推,发送简历至 zhengqingxin.dancing@bytedance.com