JS实现双指缩放

摘要

随着移动端设备的普及,作为前端开发,难免会遇到图片双指放大的需求。触控设备可以直接使用手指进行交互,而且基本上都支持多点触控。所以,查阅了网上大佬的案例,编写一个 vue 指令,来完成双指放大图片的需求。

缩放原理

原理其实很简单,双指向外扩张表示放大,向内收缩表示缩小,缩放比例是通过计算双指当前的距离 / 双指上一次的距离获得的。详见下图:

6be502dca6429723b45a1.png

计算出缩放比例后再通过下面两种方式实现缩放。

  1. 通过 transform 进行缩放
  2. 通过修改宽高来实现缩放 主流的方法都是采用 transform 来实现,因为性能更好。本篇文章两种方式都会介绍,任你选择。不过在讲之前,还是要先搞懂两个数学公式以及PointerEvent 指针事件。因为接下来会用到。如果对 PointerEvent 指针事件不太熟悉的小伙伴,也可以看看这篇文章 js PointerEvent 指针事件简单介绍。

两点间距离公式

设两个点 A、B 以及坐标分别为 A(x1, y1)、B(x2, y2),则 A 和 B 两点之间的距离为:

$ \left | AB \right | =\sqrt{(x1-x2)^2+(y1-y2)^2} $。

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取两点间距离
* @param {object} a 第一个点坐标
* @param {object} b 第二个点坐标
* @returns
*/
function getDistance(a, b) {
const x = a.x - b.x;
const y = a.y - b.y;
return Math.hypot(x, y); // Math.sqrt(x * x + y * y);
}

中点坐标公式

$ \left { x,y \right } = \left { \frac{x1+x2}{2}, \frac{y1+y2}{2} \right } $

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取中点坐标
* @param {object} a 第一个点坐标
* @param {object} b 第二个点坐标
* @returns
*/
function getCenter(a, b) {
const x = (a.x + b.x) / 2;
const y = (a.y + b.y) / 2;
return { x: x, y: y };
}

获取图片缩放尺寸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 获取图片缩放尺寸
* @param {number} naturalWidth
* @param {number} naturalHeight
* @param {number} maxWidth
* @param {number} maxHeight
* @returns
*/
function getImgSize(naturalWidth, naturalHeight, maxWidth, maxHeight) {
const imgRatio = naturalWidth / naturalHeight;
const maxRatio = maxWidth / maxHeight;
let width, height;
// 如果图片实际宽高比例 >= 显示宽高比例
if (imgRatio >= maxRatio) {
if (naturalWidth > maxWidth) {
width = maxWidth;
height = (maxWidth / naturalWidth) * naturalHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
} else {
if (naturalHeight > maxHeight) {
width = (maxHeight / naturalHeight) * naturalWidth;
height = maxHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
}
return { width: width, height: height };
}

双指缩放逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
Vue.directive("doubleswiper", {
bind: (el, binding) => {
// 全局变量
let isPointerdown = false, // 按下标识
pointers = [], // 触摸点数组
point1 = { x: 0, y: 0 }, // 第一个点坐标
point2 = { x: 0, y: 0 }, // 第二个点坐标
diff = { x: 0, y: 0 }, // 相对于上一次pointermove移动差值
lastPointermove = { x: 0, y: 0 }, // 用于计算diff
lastPoint1 = { x: 0, y: 0 }, // 上一次第一个触摸点坐标
lastPoint2 = { x: 0, y: 0 }, // 上一次第二个触摸点坐标
lastCenter; // 上一次中心点坐标

let result, // 图片缩放宽高
x, // x轴偏移量
y, // y轴偏移量
scale = 1, // 缩放比例
maxScale,
minScale = 1; // 边界恢复偏移量
let leftX = new Map(),
rightX = new Map(); // 左边的边界偏移,右边的边界偏移
let lastX, lastY, lastScale;
// 由于图片是异步加载,需要在load方法里获取naturalWidth,naturalHeight
el.addEventListener("load", function () {
result = getImgSize(
el.naturalWidth,
el.naturalHeight,
window.innerWidth,
window.innerHeight
);
maxScale = Math.max(Math.round(el.naturalWidth / result.width), 3);
// 图片宽高
el.style.width = result.width + "px";
el.style.height = result.height + "px";
// 垂直水平居中显示
x = (window.innerWidth - result.width) * 0.5;
y = (window.innerHeight - result.height) * 0.5;
lastX = x;
lastY = y;
lastScale = scale;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
});

// 图片赋值需放在load回调之后,因为图片缓存后读取很快,有可能不执行load回调
el.src = `${el.src}?time=${new Date().getTime()}`;
// 绑定 pointerdown
el.addEventListener("pointerdown", function (e) {
pointers.push(e);
point1 = { x: pointers[0].clientX, y: pointers[0].clientY };
if (pointers.length === 1) {
isPointerdown = true;
el.setPointerCapture(e.pointerId);
lastPointermove = { x: pointers[0].clientX, y: pointers[0].clientY };
} else if (pointers.length === 2) {
point2 = { x: pointers[1].clientX, y: pointers[1].clientY };
lastPoint2 = { x: pointers[1].clientX, y: pointers[1].clientY };
lastCenter = getCenter(point1, point2);
}
lastPoint1 = { x: pointers[0].clientX, y: pointers[0].clientY };
});

// 绑定 pointermove
el.addEventListener("pointermove", function (e) {
if (isPointerdown) {
handlePointers(e, "update");
const current1 = { x: pointers[0].clientX, y: pointers[0].clientY };
if (pointers.length === 1) {
lastX = x;
lastY = y;
// 单指拖动查看图片
diff.x = current1.x - lastPointermove.x;
diff.y = current1.y - lastPointermove.y;
lastPointermove = { x: current1.x, y: current1.y };
x += diff.x;
y += diff.y;
realDistance(el, e);
// el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
} else if (pointers.length === 2) {
lastX = x;
lastY = y;
lastScale = scale;

const current2 = { x: pointers[1].clientX, y: pointers[1].clientY };
// 计算相对于上一次移动距离比例 ratio > 1放大,ratio < 1缩小
let ratio =
getDistance(current1, current2) /
getDistance(lastPoint1, lastPoint2);
// 缩放比例
const _scale = scale * ratio;
if (_scale > maxScale) {
scale = maxScale;
ratio = maxScale / scale;
} else if (_scale < minScale) {
scale = minScale;
ratio = minScale / scale;
} else {
scale = _scale;
}
// 计算当前双指中心点坐标
const center = getCenter(current1, current2);
// 计算图片中心偏移量,默认transform-origin: 50% 50%
// 如果transform-origin: 30% 40%,那origin.x = (ratio - 1) * result.width * 0.3
// origin.y = (ratio - 1) * result.height * 0.4
// 如果通过修改宽高或使用transform缩放,但将transform-origin设置为左上角时。
// 可以不用计算origin,因为(ratio - 1) * result.width * 0 = 0
const origin = {
x: (ratio - 1) * result.width * 0.5,
y: (ratio - 1) * result.height * 0.5,
};

// 计算偏移量,认真思考一下为什么要这样计算(带入特定的值计算一下)
x -=
(ratio - 1) * (center.x - x) - origin.x - (center.x - lastCenter.x);
y -=
(ratio - 1) * (center.y - y) - origin.y - (center.y - lastCenter.y);
realDistance(el, e);
// el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
lastCenter = { x: center.x, y: center.y };
lastPoint1 = { x: current1.x, y: current1.y };
lastPoint2 = { x: current2.x, y: current2.y };
}
}
e.preventDefault();
});

function getBoundsByElement(el) {
const thumbAreaRect = el.getBoundingClientRect();
return {
x: thumbAreaRect.left,
y: thumbAreaRect.top,
w: thumbAreaRect.width,
};
}
/**
* @description: 判断手势是滑动
* @param {*} type 1 左移、 2 右移、3 上移、4 下移、
* @return {*}
* @Date: 2022-11-03 11:11:31
* @Author: David
*/
function distinguishSide(type) {
let moveDistanceX = Math.abs(pointers[0].clientX - point1.x);
let moveDistanceY = Math.abs(pointers[0].clientY - point1.y);
let distance = Math.max(moveDistanceX, moveDistanceY);
if (moveDistanceX > moveDistanceY && type > 2) {
return;
} else if (moveDistanceX < moveDistanceY && type <= 2) {
return;
}
if (distance >= 100) {
binding.value(type);
}
}
function realDistance(el, e) {
let { transX, transY, multiple } = getTransform(el);
let { x: actualX, y: actualY } = getBoundsByElement(el);
let scaleSize = {
width: result.width * multiple,
height: result.height * multiple,
};
let parentWidth = el.offsetParent.clientWidth;
let parentHeight = el.offsetParent.clientHeight;

if (scaleSize.width <= parentWidth) {
if (actualX < 0 && diff.x < 0) {
// 左移动要超出左边框
x = lastX;
distinguishSide(1);
} else if (actualX > parentWidth - scaleSize.width && diff.x > 0) {
// 右移且超出右边框
x = lastX;
distinguishSide(2);
}
} else {
if (actualX > 0 && diff.x > 0) {
// 放大之后向右边移动,左边的到达边界
x = lastX;
distinguishSide(2);
} else if (scaleSize.width + actualX < parentWidth && diff.x < 0) {
// 放大之后向左边移动,右边边的到达边界
x = lastX;
distinguishSide(1);
}
}

if (scaleSize.height <= parentHeight) {
if (actualY < 0 && diff.y < 0) {
// 上移要超出上边框
y = lastY;
distinguishSide(3);
} else if (actualY > parentHeight - scaleSize.height && diff.y > 0) {
// 下移且超出下边框
y = lastY;
distinguishSide(4);
}
} else {
if (actualY > 0 && diff.y > 0) {
// 放大之后向下边移动,上边的到达边界
y = lastY;
distinguishSide(4);
} else if (scaleSize.height + actualY < parentHeight && diff.y < 0) {
// 放大之后向上边移动,下边的到达边界
y = lastY;
distinguishSide(3);
}
}

el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
}

function getTransform(DOM) {
let arr = getComputedStyle(DOM).transform.split(",");
return {
transX: isNaN(+arr[arr.length - 2]) ? 0 : +arr[arr.length - 2], // 获取translateX
transY: isNaN(+arr[arr.length - 1].split(")")[0])
? 0
: +arr[arr.length - 1].split(")")[0], // 获取translateX
multiple: +arr[3], // 获取图片缩放比例
};
}

// 绑定 pointerup
el.addEventListener("pointerup", function (e) {
if (isPointerdown) {
handlePointers(e, "delete");
if (pointers.length === 0) {
isPointerdown = false;
} else if (pointers.length === 1) {
point1 = { x: pointers[0].clientX, y: pointers[0].clientY };
lastPointermove = { x: pointers[0].clientX, y: pointers[0].clientY };
}
}
});

// 绑定 pointercancel
el.addEventListener("pointercancel", function (e) {
if (isPointerdown) {
isPointerdown = false;
pointers.length = 0;
}
});

el.addEventListener("wheel", function (e) {
lastX = x;
lastY = y;
lastScale = scale;
let ratio = 1.1;
// 缩小
if (e.deltaY > 0) {
ratio = 1 / 1.1;
}
// 限制缩放倍数
const _scale = scale * ratio;
if (_scale > maxScale) {
ratio = maxScale / scale;
scale = maxScale;
} else if (_scale < minScale) {
ratio = minScale / scale;
scale = minScale;
} else {
scale = _scale;
}
// 目标元素是img说明鼠标在img上,以鼠标位置为缩放中心,否则默认以图片中心点为缩放中心
if (e.target.tagName === "IMG") {
const origin = {
x: (ratio - 1) * result.width * 0.5,
y: (ratio - 1) * result.height * 0.5,
};
// 计算偏移量
x -= (ratio - 1) * (e.clientX - x) - origin.x;
y -= (ratio - 1) * (e.clientY - y) - origin.y;
lastCenter = { x: e.clientX, y: e.clientY };
}
// el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
realDistance(el, e);
e.preventDefault();
});

/**
* 更新或删除指针
* @param {PointerEvent} e
* @param {string} type
*/
function handlePointers(e, type) {
for (let i = 0; i < pointers.length; i++) {
if (pointers[i].pointerId === e.pointerId) {
if (type === "update") {
pointers[i] = e;
} else if (type === "delete") {
pointers.splice(i, 1);
}
}
}
}
},
});

注册使用

在需要缩放的图片上使用v-doubleswiper就能完成图片的缩放和移动了。

注意事项

由于 transform 书写顺序并不满足交换律,换句话说 transform: translateX(300px) scale(2);和 transform: scale(2) translateX(300px);是不相等的。开发时请根据相应的书写顺序做处理。详见下图:

a37c583fa5cb64390179d.png

参考文章

https://juejin.cn/post/7020243158529212423#heading-6