最终效果以下:javascript
首先,须要了解 CSS3 的 transform
,用 transform
进行元素的变换,这是实现的关键。html
transform
最经常使用的形式像这样:java
// 放大 2 倍
transform: scale(2);
// 向左平移 100px
transform: translate(100px);
// rotate,skew,perspective 等其余变换
复制代码
实际上,上面的写法能够算做 CSS 提供的语法糖。了解计算机图形学的同窗可能知道,计算机完成图像变换实际上使用的实现是矩阵。git
若是使用如下 JavaScript 代码更改并查询一个 div 元素的 CSS transform 属性:github
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 用到的参数便可。web
但这绝对不是 matrix 完整的正确用法数组
若是想要多了解一些关于变换矩阵的知识,请搜索“仿射变换”。浏览器
知乎上有一个很好的入门回答:如何通俗地讲解「仿射变换」这个概念? - 马同窗的回答。微信
若是不肯意写矩阵形式,也能够将其等价地写成:
transform: translate(200px, 100px) scale(3);
复制代码
注意,书写顺序决定了变换顺序,不能够将 scale 放置在 translate 以前:Is a css transform matrix equivalent to a transform scale, skew, translate。
在进行实现以前,须要先了解一点触摸事件的处理。详见 触摸事件。
这里简单介绍一下相关的事件:
touchstart
:触摸事件开始,表示一个触摸点开始接触。能够经过传入对象获取 touches
,即一 个 TouchList
对象,里面含有当前全部的接触点,即 touch 对象。下面 2 个事件传入参数相同。
touchmove
:触摸点移动。
touchend
:触摸事件结束,表示一个触摸点离开。
TouchList
:是一种“类数组”对象,也就是和函数中拿到的 arguments
类似,不是数组,可是含有 length
属性,以及 0
、1
这样的 key 值 ,能够经过 Array.prototype.slice
转为数组。也可使用 touches['0']
这样的语法直接从 touches 中取出触摸点对象。
触摸事件传入的参数是组合对象,所以若是使用了 React 框架,最好不要向异步方法,如 setTimeout、Promise、async / await 区域中传递该参数。能够先使用变量获取须要使用的值,再进行传递。若是必定要传入,可使用 e.persist()
将对象持久化。
区别于点击事件,没法从触摸事件中直接得到 offsetX offsetY。所以须要本身计算这两个值。
网上找 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']);
});
复制代码
如今已经实现了基本的缩放功能,可是好像哪里不太对……为何感受缩放效果不是从手指中传出的呢?彷佛无论在哪里操做,都是从图片中心开始的。
简单介绍一个 CSS 属性:transform-origin
,详细介绍见 MDN。
此属性规定元素基点,也就是是应用变换的原点。
// 元素基点设置为 (50px, 50px),是元素上的相对坐标
transform-origin: 50px 50px;
复制代码
当图形变换只有位移时,transform-origin 不会有什么影响。可是对于旋转和缩放属性来讲,元素基点是重要的属性。
而默认的 transform-origin 值是 50% 50%
,也就是元素正中心。这也就是为何每次进行缩放操做,都感受缩放从图片中心点传来。
若是想要感觉缩放效果从手指开始,就要将 transform-origin 设置在双指中间的位置;或者,经过位移的计算,模拟出 origin 的变化。本文采用前一种更直观的思路。
实际上,无论元素被变换成了什么形状,设置 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
获取的位置不正确,本身实现了一下。思路是利用父定位元素累加 offset,喜欢挑战的朋友务必本身尝试一下,有意外惊喜哦)
至于“所选的屏幕位置“,取双指中点的位置。这里选取 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……多操做一下就能发现,每次缩放离手后再次进行缩放,目标对象彻底不受控制,甚至会瞬移。
稍微思考,咱们就能发现问题所在(不存在的,我 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 倍)就变成了:
此时点 A'' 的坐标是 加上点 O 的坐标:
也就是说,将基点 O 从 移动到
,记增量为
,致使了点 A 的变换结果,从
,变成了
。计算 A 点缩放后的图像由于元素基点移动而变化了的值,也就是点 A'' 到点 A' 的距离:
能够带入上面图片中的真实坐标值进行验证,结果是符合预期的。
这样就解释得通了,在双指落下的一瞬间,origin 坐标变化了 ,而取任变换结果上的一点 X ,变化了
。观察发现,这个值与点 X 自身的坐标没有任何关系,是 origin 移动距离决定的一个“定值”;也就是说,元素上的全部的点,同时产生了这端位移的变换效果。反映到界面上来,就是双指接触元素的瞬间,元素马上“瞬移”一下。而随着手指不断改变位置,origin 不断被重设,因而形成了缩放元素彻底不受控制的局面。
要消除修改 origin 带来的负面影响,有 2 点须要作:
以前的计算中,咱们获得了元素发生了 的平移,因而只须要在修改 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。
通过评论区朋友提醒,我发现以前使用的 overscroll-behavior: contain;
属性对于微信 X5 等有中国特点的浏览器(如夸克浏览器,nothing personal)没有做用,仍然会出现下拉刷新等问题。
若是仅需补充对图片拖拽时的浏览器固定,添加一句代码足矣:
img.addEventListener('touchmove', e => {
// ...
e.preventDefault();
};
复制代码
但若是还想要屏蔽浏览器自定义的下拉事件,那就要费一番力气了。下面的代码实现了对页面所有 touchmove 事件的阻止,这其中也包括了长页面的滚动事件。
// 检查是否支持 options 写法
let passiveSupport = false;
try {
let option = Object.defineProperty({}, 'passive', {
get: () => {
passiveSupport = true;
}
});
window.addEventListener('passivetest', null, option);
} catch (err) {}
document.body.addEventListener('touchmove', (e) => {
e.preventDefault();
}, passiveSupport ? { passive: false } : false);
复制代码
若是对于 passive 的写法感到奇怪,请移步此文:关于passive event listener的一次踩坑;以及 addEventListener 的官方文档:EventTarget.addEventListener(),还有谷歌对于 passive 合理性的解释:Improving Scroll Performance with Passive Event Listeners。
我尝试了在 body 位于页面顶部并继续下拉的时刻调用 e.preventDefault()
,这在直接向下拉动页面时有效。但浏览器有一项规则:不容许打断一次连续的 scroll。也就是说,若是先向上,再向下拉动页面,页面仍然有机会进行默认的下拉表现。
简单来讲,若是想要同时禁止下拉事件,并完美兼容 scroll 动做,现阶段多是作不到的。(若是有高手作到了,务必请不吝赐教)
另外,上面的代码其实能够用一句 CSS 代码代替,若是你不在乎 Safari 全系列不兼容 的话:
html { touch-action: none; }
复制代码