js 实现双指缩放

12,453 阅读4分钟

前言

随着智能手机、平板电脑等触控设备的普及,交互方式也发生了改变。相对于使用鼠标和键盘进行交互的电脑,触控设备可以直接使用手指进行交互,而且基本上都支持多点触控。多点触控最常见的操作莫过于双指缩放了。比如双指缩放网页大小、朋友圈双指缩放图片进行查看。那么如此常见的手势操作,你有没有想过它是如何实现的呢?下面跟着我一探究竟吧!

缩放原理

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

p.jpg

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

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

两点间距离公式

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

AB=()|AB| = √()

e693d73856f43706273b0197b3cc42bf.svg

/**
 * 获取两点间距离
 * @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);
}

中点坐标公式

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

4a36acaf2edda3cce013415d11e93901203f92dc.png

/**
 * 获取中点坐标
 * @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 };
}

获取图片缩放尺寸

<img id="image" alt="">
const image = document.getElementById('image');

let result, // 图片缩放宽高
    x, // x轴偏移量
    y, // y轴偏移量
    scale = 1, // 缩放比例
    maxScale,
    minScale = 0.5;

// 由于图片是异步加载,需要在load方法里获取naturalWidth,naturalHeight
image.addEventListener('load', function () {
    result = getImgSize(image.naturalWidth, image.naturalHeight, window.innerWidth, window.innerHeight);
    maxScale = Math.max(Math.round(image.naturalWidth / result.width), 3);
    // 图片宽高
    image.style.width = result.width + 'px';
    image.style.height = result.height + 'px';
    // 垂直水平居中显示
    x = (window.innerWidth - result.width) * 0.5;
    y = (window.innerHeight - result.height) * 0.5;
    image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)';
});

// 图片赋值需放在load回调之后,因为图片缓存后读取很快,有可能不执行load回调
image.src='../images/xxx.jpg';

/**
 * 获取图片缩放尺寸
 * @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 }
}

双指缩放逻辑

// 全局变量
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; // 上一次中心点坐标
    
// 绑定 pointerdown
image.addEventListener('pointerdown', function (e) {
    pointers.push(e);
    point1 = { x: pointers[0].clientX, y: pointers[0].clientY };
    if (pointers.length === 1) {
        isPointerdown = true;
        image.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
image.addEventListener('pointermove', function (e) {
    if (isPointerdown) {
        handlePointers(e, 'update');
        const current1 = { x: pointers[0].clientX, y: pointers[0].clientY };
        if (pointers.length === 1) {
            // 单指拖动查看图片
            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;
            image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
        } else if (pointers.length === 2) {
            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) {
                ratio = maxScale / scale;
                scale = maxScale;
            } else if (_scale < minScale) {
                ratio = minScale / scale;
                scale = minScale;
            } 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);
            image.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();
});

// 绑定 pointerup
image.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
image.addEventListener('pointercancel', function (e) {
    if (isPointerdown) {
        isPointerdown = false;
        pointers.length = 0;
    }
});

/**
 * 更新或删除指针
 * @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);
            }
        }
    }
}

注意事项

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

微信图片_20210802192116.png

效果演示

Demo:jsdemo.codeman.top/html/pinch.…

二维码图片_8月3日14时14分23秒.png

写在最后

我已将常用的手势例如单击,双击,长按,滑动,拖拽,滚轮缩放,双指缩放,双指旋转等封装成了插件。项目已在Github开源,感兴趣的小伙伴可以去看看。项目地址:github.com/18223781723…