摘要
随着移动端设备的普及,作为前端开发,难免会遇到图片双指放大的需求。触控设备可以直接使用手指进行交互,而且基本上都支持多点触控。所以,查阅了网上大佬的案例,编写一个vue指令,来完成双指放大图片的需求。
缩放原理
原理其实很简单,双指向外扩张表示放大,向内收缩表示缩小,缩放比例是通过计算双指当前的距离 / 双指上一次的距离获得的。详见下图:
计算出缩放比例后再通过下面两种方式实现缩放。
- 通过transform进行缩放
- 通过修改宽高来实现缩放 主流的方法都是采用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} 。
/**
* 获取两点间距离
* @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 \}
/**
* 获取中点坐标
* @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 };
}
获取图片缩放尺寸
/**
* 获取图片缩放尺寸
* @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 }
}
双指缩放逻辑
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);是不相等的。开发时请根据相应的书写顺序做处理。详见下图:
参考文章
https://juejin.cn/post/7020243158529212423#heading-6
评论区