一步步实现网页图片的手势拖拽与缩放

栏目: CSS · 发布时间: 5年前

内容简介:首先,需要了解 CSS3 的实际上,上面的写法可以算作 CSS 提供的语法糖。了解计算机图形学的同学可能知道,计算机完成图像变换实际上使用的实现是矩阵。

首先,需要了解 CSS3 的 transform ,用 transform 进行元素的变换,这是实现的关键。

transform 最常用的形式像这样:

// 放大 2 倍
transform: scale(2);

// 向左平移 100px
transform: translate(100px);

// rotate,skew,perspective 等其他变换
复制代码

实际上,上面的写法可以算作 CSS 提供的语法糖。了解计算机图形学的同学可能知道,计算机完成图像变换实际上使用的实现是矩阵。

如果使用以下 JavaScript 代码更改并查询一个 div 元素的 CSS transform 属性:

document.querySelector('div').style.transform = 'scale(1)';
console.log(window.getComputedStyle(document.querySelector('div'), null).getPropertyValue('transform'));

// 输出 "matrix(1, 0, 0, 1, 0, 0)"
复制代码

可以看到此时 transform 的值并不是“scale(1)”,而是一个矩阵表示。此处 matrix 中的 6 个参数,对应了 2D 仿射变换矩阵中起作用的 6 个值(完整的是 3*3 矩阵,但是有 3 个参数是固定的)——不过这跟本文的实现没有太大关系。为了简单起见,只需知道在 matrix 用到的参数即可。

一步步实现网页图片的手势拖拽与缩放

但这绝对不是 matrix 完整的正确用法

如果想要多了解一些关于变换矩阵的知识,请搜索“仿射变换”。

知乎上有一个很好的入门回答: 如何通俗地讲解「仿射变换」这个概念? - 马同学的回答

如果不愿意写矩阵形式,也可以将其等价地写成:

transform: translate(200px, 100px) scale(3);
复制代码

注意, 书写顺序决定了变换顺序,不可以将 scale 放置在 translate 之前Is a css transform matrix equivalent to a transform scale, skew, translate

Touch 事件

在进行实现之前,需要先了解一点触摸事件的处理。详见触摸事件。

这里简单介绍一下相关的事件:

touchstart :触摸事件开始,表示一个触摸点开始接触。可以通过传入对象获取 touches ,即一 个 TouchList 对象,里面含有当前所有的接触点,即touch 对象。下面 2 个事件传入参数相同。

touchmove :触摸点移动。

touchend :触摸事件结束,表示一个触摸点离开。

TouchList :是一种“类数组”对象,也就是和函数中拿到的 arguments 相似,不是数组,但是含有 length 属性,以及 01 这样的 key 值 ,可以通过 Array.prototype.slice 转为数组。也可以使用 touches['0'] 这样的语法直接从 touches 中取出触摸点对象。

需要了解

拖拽的实现

网上找 DOM 元素拖拽,通常的做法是使用相对定位与 top、left 属性。但是结合缩放事件,本文将使用 transform 进行实现。

不过无论具体实现方式如何,移动元素的思想都是一致的:先计算两次 move 事件中的触摸位移,然后将这段位移应用到目标上。

HTML 部分:

<head>
  <meta charset="UTF-8">
  <!--一些方便实现的声明-->
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0" />
  <title>Touch</title>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      width: 100%;
      // 禁用页面拖动刷新
      overscroll-behavior: contain;
    }
    .board {
      width: 100%;
      height: 100%;
    }
    .board img {
      width: 260px;
    }
  </style>
</head>

<body>
  <div class="board">
    <!--盗了少数派的图-->
    <img src="https://cdn.sspai.com/article/86c69914-4545-bc1c-1310-2975d4fe8d6b.jpg?imageMogr2/quality/95/thumbnail/!700x233r/gravity/Center/crop/700x233" alt="">
  </div>
</body>
复制代码

JavaScript 部分:

let img = document.querySelector('img');

// 查询 DOM 对象的 CSS 值
const getStyle = (target, style) => {
  let styles = window.getComputedStyle(target, null);
  return styles.getPropertyValue(style);
};

// 获取并解析元素当前的位移量
const getTranslate = (target) => {
  let matrix = getStyle(target, 'transform');
  let nums = matrix.substring(7, matrix.length - 1).split(', ');
  let left = parseInt(nums[4]) || 0;
  let top = parseInt(nums[5]) || 0;
  return { left: left, top: top };
};

// 记录前一次触摸点的位置
let preTouchPosition = {};
const recordPreTouchPosition = (touch) => {
  preTouchPosition = {
    x: touch.clientX,
    y: touch.clientY
  };
};

// 应用样式变换
const setStyle = (key, value) => { img.style[key] = value; };

// 添加触摸移动的响应事件
img.addEventListener('touchmove', e => {
  let touch = e.touches[0];
  let translated = getTranslate(touch.target);
  // 移动后的位置 = 当前位置 + (此刻触摸点位置 - 上一次触摸点位置)
  let translateX = translated.left + (touch.clientX - preTouchPosition.x);
  let translateY = translated.top + (touch.clientY - preTouchPosition.y);

  let matrix = `matrix(1, 0, 0, 1, ${translateX}, ${translateY})`;
  setStyle('transform', matrix);

  // 完成一次移动后,要及时更新前一次触摸点的位置
  recordPreTouchPosition(touch);
});

// 开始触摸时记录触摸点的位置
img.addEventListener('touchstart', e => { recordPreTouchPosition(e.touches['0']); });
复制代码

缩放的实现

初步

要进行缩放,就要知道缩放的倍数。进行缩放是双指的动作,有 2 个触摸点,而将触摸点之间的距离变化对应到缩放倍率的变化,就可以实现双指缩放的效果。要得知缩放的变化,思路跟移动一致,也是要记录上次的触摸点距离。然后就可以计算现在的缩放倍率。

let scaleRatio = 1;
// 从变量名就知道它的用途与用法
let preTouchesClientx1y1x2y2 = [];
img.addEventListener('touchmove', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    // 即便同时落下 10 个手指,我们只取前 2 个就好
    let one = touches['0'];
    let two = touches['1'];
    const distance = (x1, y1, x2, y2) => {
      let a = x1 - x2;
      let b = y1 - y2;
      return Math.sqrt(a * a + b * b);
    };
    // 新的缩放倍率 = (当前指间距离 ÷ 之前指间距离)× 之前缩放倍率
    // 没有在 touchstart 中记录最初的双指位置,计算会得到 NaN,对结果直接取 1
    scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
    let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
    setStyle('transform', matrix);
    // 及时更新双指位置信息
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
});
img.addEventListener('touchstart', e => {
  let touches = e.touches;
  // 双指同时落下也是有先后顺序的,当发现多指触摸时进行记录
  if (touches.length > 1) {
    let one = touches['0'];
    let two = touches['1'];
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
  recordPreTouchPosition(touches['0']);
});
复制代码

现在已经实现了基本的缩放功能,但是好像哪里不太对……为什么感觉缩放效果不是从手指中传出的呢?似乎不管在哪里操作,都是从图片中心开始的。

transform-origin

简单介绍一个 CSS 属性: transform-origin ,详细介绍见MDN。

此属性规定元素基点,也就是是应用变换的原点。

// 元素基点设置为 (50px, 50px),是元素上的相对坐标
transform-origin: 50px 50px;
复制代码

当图形变换只有位移时,transform-origin 不会有什么影响。但是对于旋转和缩放属性来说,元素基点是重要的属性。

而默认的 transform-origin 值是 50% 50% ,也就是元素正中心。这也就是为什么每次进行缩放操作,都感觉缩放从图片中心点传来。

如果想要感受缩放效果从手指开始,就要将 transform-origin 设置在双指中间的位置;或者,通过位移的计算,模拟出 origin 的变化。本文采用前一种更直观的思路。

获取触摸的 offset

实际上,不管元素被变换成了什么形状,设置 origin 时都是采用相对元素变换前的偏移量。之前提到过 touch 事件中并没有触摸点相对于元素的 offset 值,因此需要自己来计算。

// 计算相对缩放前的偏移量,rect 为当前变换后元素的四周的位置
const relativeCoordinate = (x, y, rect) => {
  let cx = (x - rect.left) / scaleRatio;
  let cy = (y - rect.top) / scaleRatio;
  return {
    x: cx,
    y: cy
  };
};
复制代码

其实就是 (所选的屏幕位置 - 元素的屏幕位置) / 缩放比例 ,并不困难。rect 可以直接使用 getBoundingClientRect 函数获得。(之前误以为 getBoundingClientRect 获取的位置不正确,自己实现了一下 一步步实现网页图片的手势拖拽与缩放

至于“所选的屏幕位置“,取双指中点的位置。这里选取 clientX 和 clientY 值计算,即距离浏览器的偏移量。

// 记录变换基点
let scaleOrigin = {};
img.addEventListener('touchmove', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    let one = touches['0'];
    let two = touches['1'];
    const distance = (x1, y1, x2, y2) => {
      let a = x1 - x2;
      let b = y1 - y2;
      return Math.sqrt(a * a + b * b);
    };
    scaleRatio = distance(one.clientX, one.clientY, two.clientX, two.clientY) / distance(...preTouchesClientx1y1x2y2) * scaleRatio || 1;
    // 移动基点
    let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2, img.getBoundingClientRect());
    scaleOrigin = origin;
    setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
      
    let matrix = `matrix(${scaleRatio}, 0, 0, ${scaleRatio}, ${translateX}, ${translateY})`;
    setStyle('transform', matrix);
    preTouchesClientx1y1x2y2 = [one.clientX, one.clientY, two.clientX, two.clientY];
  }
});
复制代码

似乎完成了?上手试一下。emmm……多操作一下就能发现,每次缩放离手后再次进行缩放,目标对象完全不受控制,甚至会瞬移。

修改 transform-origin 带来的问题

稍微思考,我们就能发现问题所在(不存在的,我 debug 好久):对于已经应用过缩放(或旋转)的元素,修改 origin 位置时,会产生位置的突然变化。

具体是怎么回事呢?其实这是一个高中数学就能够解释问题。

一点高中数学

一步步实现网页图片的手势拖拽与缩放

以上是元素基点位于原点的情况。此时缩放倍率为 2,缩放前的点 A 坐标为 (3, 2),变换后 A' 为 (6, 4)。

那么,如果 origin 不在原点呢?将 origin 移动到 (1, 1) 时,情况如下:

一步步实现网页图片的手势拖拽与缩放

可以看到,A' 点的坐标变为了 (5, 3)。其实现在从数值上已经可以看出一点端倪了,但是让我们来做一点抽象归纳。

首先,将基点 O 设为 。此时如果点 A 坐标为 ,缩放倍率为 s 。用向量来表示点 A 到基点的距离就是:

那么此时点 A' 到基点的距离正是 的 s 倍:

点 A' 的坐标即 的值加上点 O 的坐标:

如果我们移动基点 O,现在点 O 的坐标变为了: 。我们没有改变坐标系参考点,点 A 的坐标仍是 ,此时点 A 到基点 O 的距离为:

而新由点 A 变换得到的点 A'' 到基点 O 的距离( 的 s 倍)就变成了:

此时点的坐标, 加上点 O 的坐标:

也就是说,**将基点 O 从 移动到 ,记增量为 ,导致了点 A 的变换结果,从 ,变成了 **。计算一下 A 点因为元素基点而改变的值,也就是点 A'' 到点 A' 的距离:

可以带入上面的真实坐标值进行验证,结果是符合预期的。

消除修改 origin 位置带来的影响

这样就解释得通了,在 双指落下的一瞬间,origin 坐标变化了 ,而任一点 A 则变化了 。观察发现,这个值与点 A 自身的坐标 没有任何关系,是 origin 移动距离决定了的一个“定值”;也就是说,元素上的所有的点,同时产生了这个坐标位移效果。反映到界面上来,就是双指接触到元素的瞬间,元素立刻“瞬移”一下。而随着手指不断改变位置,origin 不断被重设,于是造成了缩放元素完全不受控制的局面。

要消除修改 origin 带来的负面影响,有 2 点需要做:

  • 修改 origin 的同时修改位移,使得目标点的位移效果被抵消
  • 减少 origin 的修改次数,可以减少不必要的计算量

进行修正

之前的计算中,我们得到了元素发生了 的平移,于是只需要在修改 origin 位置的同时,将位移量提前减去这个值即可。另外,我们将 origin 的修改频率从每个 touchmove 事件进行一次,减少到完整的一段缩放交互进行一次。

// 增加 originHaveSet 全局变量,每次设置 origin 位置后设为 true
img.addEventListener('touchmove', e => {
  // ...
  if (!originHaveSet) {
    originHaveSet = true;
    // 移动视线中心
    let origin = relativeCoordinate((one.clientX + two.clientX) / 2, (one.clientY + two.clientY) / 2, 
  img.getBoundingClientRect());
    // 修正视野变化带来的平移量,别忘了加上之前已有的位移值啊!
    translateX = (scaleRatio - 1) * (origin.x - scaleOrigin.x) + translateX;
    translateY = (scaleRatio - 1) * (origin.y - scaleOrigin.y) + translateY;
    setStyle('transform-origin', `${origin.x}px ${origin.y}px`);
    scaleOrigin = origin;
  }
  // ...
});
img.addEventListener('touchstart', e => {
  let touches = e.touches;
  if (touches.length > 1) {
    // ... 开始缩放事件时,将标志置为 false
    originHaveSet = false;
  } //...
});
复制代码

这时再看一下效果,不禁流下了感动的泪水。终于能够正常缩放了,这完美的跟手效果,这顺滑的缩放体验……

稍等,缩放后拿开手指,为什么图片有时候还是会跳动啊。

一点尾巴

仔细检查一下代码,定位发现是单手 touchmove 的问题:双手缩放后移开,有时会触发单手的一个 touchmove 逻辑;而之前拖拽实现时用到的上一次接触点位置并没有及时更新,导致了计算出的图片移动距离与实际不符。

那么在 touchend 与 touchcancel 中加入相同的更新逻辑即可:

img.addEventListener('touchend', e => {
  let touches = e.touches;
  if (touches.length === 1) {
    recordPreTouchPosition(touches['0']);
  }
});
// touchcancel 一样
复制代码

最后,完整的代码可以在我的 github 上获得:html-drag-scale-demo。


以上所述就是小编给大家介绍的《一步步实现网页图片的手势拖拽与缩放》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

运营之光 2.0

运营之光 2.0

黄有璨 / 电子工业出版社 / 2017-4 / 99

在互联网行业内,“运营”这个职能发展到一定阶段后,往往更需要有成熟的知识体系和工作方法来给予行业从业者以指引。 《运营之光:我的互联网运营方法论与自白 2.0》尤其难得之处在于:它既对“什么是运营”这样的概念认知类问题进行了解读,又带有大量实际的工作技巧、工作思维和工作方法,还包含了很多对于运营的思考、宏观分析和建议,可谓内容完整而全面,同时书中加入了作者亲历的大量真实案例,让全书读起来深入......一起来看看 《运营之光 2.0》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具