Web编程技术营地
研究、演示、创新

手势触控事件

撰写时间:2025-08-13

修订时间:2025-08-22

搭建测试环境

let canvas, ctx; let transformOffsetX = 0; let transformOffsetY = 0; function initCanvas(borderWidth = '10px') { canvas = document.createElement('canvas'); pc.appendChild(canvas); pc.appendInlineStyle(` ul.pageconsole-output { & > li:has(canvas) { padding-left: 0em; & > canvas { display: block; box-sizing: border-box; border: ${borderWidth} solid #999; margin: 0.5em auto; } } } `); canvas.style.width = '80%'; canvas.style.height = '350px'; canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; ctx = canvas.getContext('2d'); ctx.scale(devicePixelRatio, devicePixelRatio); clearCanvas(); } function clearCanvas() { ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight); ctx.fillStyle = '#222'; ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight); } function render(callbackFn) { clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); if (callbackFn) { callbackFn(ctx); } ctx.restore(); } function drawOnRender() { ctx.beginPath(); ctx.arc(100, 100, 30, 0, Math.PI * 2, false); ctx.fillStyle = 'hotpink'; ctx.fill(); } initCanvas('0px'); render(drawOnRender); pc.log('%O', canvas);

上面代码,创建了一个canvas,宽度为视口的80%,高度为固定值350px,边框厚度为0px,水平居中此canvas。应用devicePixelRatio以获取高清图像。在render函数中,先平移特定的偏移值后再绘制图像。最后,打印出HTMLCanvasElement的信息。

上面在设置canvasclientWidthclientHeight属性时,若使用内联的CSS来设置较容易引发一些小问题,因此上面使用JavaScript来设置值。目前各浏览器对这些相应的属性值的设置,逻辑较混乱。具体参见Canvas 2D 概述

因下面各节需考查canvas在未设置或设置边框厚度的不同情况,因此initCanvas函数带有一个可初始化其边框厚度的参数,默认值为字符串数值10px

canvas未设置边框厚度时,为显式将其与父元素在视觉上区分开来,故用一种黑色来填充其背景。

隐藏细节,聚焦关注点

通过本站的 Web 组件,将众多辅助代码隐藏起来,让其在后台自动运行,前端代码只保留可能会变化的代码,以聚焦新的关注点。

function drawOnRender(ctx) { ctx.beginPath(); ctx.arc(100, 100, 30, 0, Math.PI * 2, false); ctx.fillStyle = 'cornflowerblue'; ctx.fill(); } initCanvas('0px'); render(drawOnRender);

mousemove事件

本文虽阐述手势触控事件,但其与鼠标事件有一些相同部分,故本节先考查mousemove事件。

7种坐标值

在鼠标移动事件中,回调函数中的参数的类型为MouseEvent,通过各个属性值存储了7种坐标值。

function initWebEvents() { canvas.addEventListener('mousemove', (evt) => { pc.groupStatic('argument', 'Mouse Event Argument'); pc.log(evt); pc.groupEnd(); pc.groupStatic('coordinates', 'Mouse Event Argument Coordinates'); pc.group('(screenX, screenY)'); pc.log(evt.screenX, evt.screenY); pc.groupEnd(); pc.group('(clientX, clientY)'); pc.log(evt.clientX, evt.clientY); pc.groupEnd(); pc.group('(movementX, movementY)'); pc.log(evt.movementX, evt.movementY); pc.groupEnd(); pc.group('(offsetX, offsetY)'); pc.log(evt.offsetX, evt.offsetY); pc.groupEnd(); pc.group('(x, y)'); pc.log(evt.x, evt.y); pc.groupEnd(); pc.group('(layerX, layerY)'); pc.log(evt.layerX, evt.layerY); pc.groupEnd(); pc.group('(pageX, pageY)'); pc.log(evt.pageX, evt.pageY); pc.groupEnd(); pc.groupEnd(); }); } initCanvas('0px'); initWebEvents();

canvas上移动鼠标,观察各种值的变化。鼠标坐标值的类型都是整数。

offsetX, offsetY

对于一个基于canvas的应用来讲,offsetXoffsetY是最方便的一组坐标值,它们是相对于canvas左上角顶点的偏移值,因此本应无需进行减法运算即可直接返回鼠标在canvas内的坐标值。

但,对于像基于canvas的应用来讲,往往需要精确到像素级别,此时仍有一些小问题需要注意。

下面主要考查两个问题。一是当canvas未设置边框厚度时,鼠标位置的问题。二是当canvas设置了边框厚度时的问题。

问题一:鼠标位置的问题

let offsetX, offsetY; let canvasBorderWidth; function initWebEvents() { canvas.addEventListener('mousemove', (evt) => { offsetX = evt.offsetX; offsetY = evt.offsetY; pc.groupStatic('offset', '(offsetX, offsetY)'); pc.log(offsetX, offsetY); pc.groupEnd(); pc.groupStatic('canvas-border-width', 'Canvas border width'); let computedStyle = getComputedStyle(canvas); pc.log(computedStyle.borderWidth); canvasBorderWidth = parseInt(computedStyle.borderWidth); pc.groupEnd(); render(drawOnRender); }); } function drawOnRender(ctx) { ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; ctx.strokeRect(offsetX - 1, offsetY - 1, 20, 20); } initCanvas('0px'); initWebEvents();

Safari的问题:参数evtoffsetY可以返回的值最小值为-1,这属于不正常的情况;而offsetX可以返回的值最小值为0,这属于正常的情况。从逻辑上讲,当鼠标移出canvas的上边框时,此时已超出canvas的监听范围,不应再返回一个-1的值。

Chrome的问题:根据参数所绘制出来的正方形,比鼠标箭头标识图像向右向下各偏移了1个像素。Safari也偏移了一点点,但不如Chrome明显。因此,上面代码在绘制正方形时将参数位置往左往上分别偏移了1个像素,从而在SafariChrome中均取得一致的效果。

问题二:canvas边框厚度的问题

let offsetX, offsetY; let canvasBorderWidth; function initWebEvents() { canvas.addEventListener('mousemove', (evt) => { offsetX = evt.offsetX; offsetY = evt.offsetY; pc.groupStatic('offset', '(offsetX, offsetY)'); pc.log(offsetX, offsetY); pc.groupEnd(); pc.groupStatic('canvas-border-width', 'Canvas border width'); let computedStyle = getComputedStyle(canvas); pc.log(computedStyle.borderWidth); canvasBorderWidth = parseInt(computedStyle.borderWidth); pc.groupEnd(); render(drawOnRender); }); } function drawOnRender(ctx) { ctx.strokeStyle = '#ccc'; ctx.lineWidth = 1; ctx.strokeRect(offsetX - 1, offsetY - 1, 20, 20); } initCanvas(); initWebEvents();

canvas的边框厚度大于0值,将鼠标移到左边或上边的边框线范围时,对于Chrome,参数evtoffsetXoffsetY均返回一个负值;左上角边框线范围外往下往右一个像素才返回0值。这使得上面代码的运行结果符合我们的期待。

但在Safari中,参数evtoffsetXoffsetY并未考虑边框线厚度的情况。当鼠标移至边框线左上角,offsetXoffsetY的值均为0;而在画布有效渲染区域内(上面黑色区域),offsetXoffsetY的值均为10。这样,我们原来正常运行的代码,当canvas有特定厚度值时,所绘制正方形将自动往下往右偏移了这个厚度值,从而导致光标箭头提示图像与所绘制的正方形不再保持一致。

如何解决这种在不同浏览器中出现不同情况的问题?一种方法是查询用户所用浏览器的类型,然后再分别对待。但这种方法需对所有主流浏览器都要进行测试。因此这种方法有点笨拙。第二种方法是改用参数evtclientXclientY属性来计算出实际偏移值。具体细节,详见下节。

clientX, clientY

clientXclientY是控件相对于视口 (viewport)的一组坐标值。视口是浏览器用以显示整个网页内容的区域的一部分。

浏览器由地址栏、收藏栏、标签页、边栏、滚动条、状态栏,以及网页内容渲染区等构成。网页内容渲染区的大小可通过以下代码查看:

pc.log(document.body.clientLeft); pc.log(document.body.clientTop); pc.log(document.body.clientWidth); pc.log(document.body.clientHeight);

因此,网页内容渲染区通常也称为浏览器客户端区域。当某个网页的内容较多时,屏幕只能看到一部分的内容。在屏幕上所看到内容的区域称为视口,因此视口中的内容只是浏览器客户端区域的一部分。当用户上下翻页时,浏览器客户端区域中的其他内容则相应地滚动到视口中,从而能被用户阅读。

mousemove事件中参数evtclientXclientY属性值返回鼠标相对于视口的偏移值。

下面的代码,当鼠标在浏览器整个视口移动时,将显示鼠标在视口中的偏移位置值。

let clientX, clientY; function initWebEvents() { document.body.addEventListener('mousemove', (evt) => { clientX = evt.clientX; clientY = evt.clientY; pc.groupStatic('client', '(clientX, clientY)'); pc.log(clientX, clientY); pc.groupEnd(); }); } initWebEvents();

clientXclientY的数据类型为整数。

在不改变浏览器大小的情况下,视口区域的大小是固定的,则mousemove事件参数evt中的clientXclientY的值是在视口区域内的偏移值,因此它们与网页是否翻页无关。

使用鼠标滚轮上下滑动网页,网页的内容将上下滚动,但clientXclientY的值总是保持不变。在滑动时观察鼠标的位置,您会发现它在滑动过程中不会动,也即它相对于视口区域的位置不变,因此clientXclientY的值也不会变。

上下翻页不会改变视口区域的大小及位置,但翻页会改变各个具体控件在视口区域中的相对位置。同样使用鼠标滚轮上下滑动网页,鼠标位置不会变,但您会发现鼠标所指向的网页内容总在不同的变化。

也即说,随着网页的上下翻动,每个具体控件在视口中的相对位置会随之而改变。

下面代码让canvas元素监听鼠标移动事件并查看clientXclientY的值的情况。滚动网页并在canvas内移动鼠标,观察clientY值的变化。

let clientX, clientY; function initWebEvents() { canvas.addEventListener('mousemove', (evt) => { clientX = evt.clientX; clientY = evt.clientY; pc.groupStatic('client', '(clientX, clientY)'); pc.log(`(%d, %d)`, clientX, clientY); pc.groupEnd(); }); } initCanvas(); initWebEvents();

现在,我们根据clientXclientY值,通过计算求出鼠标在canvas中的相对位置,然后在canvas中相应位置绘制一个小圆圈。

let clientX, clientY; let canvasBorderWidth; let computedOffetX, computedOffsetY; function initWebEvents() { canvas.addEventListener('mousemove', (evt) => { clientX = evt.clientX; clientY = evt.clientY; pc.groupStatic('client', '(clientX, clientY)'); pc.log(clientX, clientY); pc.groupEnd(); pc.groupStatic('canvas-bcr', 'Canvas bounding rect (x, y, w, h)'); let bcr = canvas.getBoundingClientRect(); const {x, y, width, height} = bcr; pc.log(x, y, width, height); pc.groupEnd(); pc.groupStatic('canvas-border-width', 'Canvas border width'); let computedStyle = getComputedStyle(canvas); pc.log(computedStyle.borderWidth); canvasBorderWidth = parseInt(computedStyle.borderWidth); pc.groupEnd(); pc.groupStatic('computed', 'computed (offsetX, offsetY)'); computedOffetX = Math.round(clientX - x) - canvasBorderWidth; computedOffetY = Math.round(clientY - y) - canvasBorderWidth; pc.log(`(%d, %d)`, computedOffetX, computedOffetY); pc.groupEnd(); render(drawOnRender); }); } function drawOnRender(ctx) { ctx.beginPath(); ctx.arc(computedOffetX, computedOffetY, 10, 0, Math.PI * 2, false); ctx.strokeStyle = '#ccc'; ctx.stroke(); ctx.fillStyle = 'gray'; ctx.fill(); } initCanvas(); initWebEvents(); pc.log('%O', canvas);

HTMLCanvasElementprototype链中,可调用ElementgetBoundingClientRect方法来获取该元素在视口中的方框。

  • HTMLCanvasElement
    • HTMLElement
      • Element: getBoundingClientRect()
        • Node
          • EventTarget
            • Object

可以看出,其返回值的各个属性值均为浮点数。

使用clientXclientY分别减去此方框左上角对应轴向上的坐标值,四舍五入后,再减去canvas的边框厚度值,即可得到鼠标在canvas中的偏移值。

pageX, pageY

pageXpageY返回鼠标相对于整个浏览器客户端区域的偏移值。

let pageX, pageY; let computedOffetX, computedOffsetY; function initWebEvents() { let canvasParent = canvas.parentNode; canvasParent.addEventListener('mousemove', (evt) => { pageX = evt.pageX; pageY = evt.pageY; pc.groupStatic('page', '(pageX, pageY)'); pc.log(`(%d, %d)`, pageX, pageY); pc.groupEnd(); pc.groupStatic('canvas', 'canvas'); pc.group('offsetParent'); pc.log('%s', canvas.offsetParent.tagName); pc.groupEnd(); pc.group('(offsetLeft, offsetTop, offsetWidth, offsetHeight)'); const {offsetLeft, offsetTop, offsetWidth, offsetHeight} = canvas; pc.log(offsetLeft, offsetTop, offsetWidth, offsetHeight); pc.groupEnd(); pc.group('border-width'); let computedStyle = getComputedStyle(canvas); pc.log(computedStyle.borderWidth); pc.groupEnd(); pc.groupEnd(); pc.groupStatic('computed', 'computed (offsetX, offsetY)'); computedOffetX = pageX - canvas.offsetLeft - parseInt(computedStyle.borderWidth); computedOffetY = pageY - canvas.offsetTop - parseInt(computedStyle.borderWidth); pc.log(computedOffetX, computedOffetY); pc.groupEnd(); render(drawOnRender); }); } function drawOnRender(ctx) { ctx.beginPath(); ctx.arc(computedOffetX, computedOffetY, 10, 0, Math.PI * 2, false); ctx.strokeStyle = '#ccc'; ctx.stroke(); ctx.fillStyle = 'gray'; ctx.fill(); } initCanvas(); initWebEvents(); pc.log('%O', canvas);

offsetParent属性值(属于prototype链中的HTMLElement)返回特定子元素最近的可协助其定位的父元素。offsetLeftoffsetTop返回子元素相对于offsetParent父元素的偏移值。

由于canvasoffsetParent父元素是body,因此,offsetLeftoffsetTop属性值就代表了canvas在浏览器客户端区域中的偏移值。使用pageXpageY分别减去它们,就可以得出鼠标相对于canvas的偏移值。再减去canvas的边框厚度,最终得到canvas的有效渲染区域内的坐标值。

调试基于触控事件的应用

在桌面浏览器中调试

Chrome

打开开发者工具面板,点击该面板的左上角第2个图标显示/隐藏设备工具栏

chrome responsive mode

可切换进入响应式设计模式 (responsive design mode)。

Chrome的优点是,在响应式设计模式下,可用鼠标来模拟手势触控。

Safari

按下⌃Control+⌘Command+R,进入响应式设计模式。在此模式下,也可通过iOS设备模拟器来访问。

Safari的不足是,不能使用鼠标来模拟手势触控(桌面版的Safari未实现TouchEvent对象);并且iOS设备模拟器不能直接查看终端输出。

在移动设备上调试

在有Wifi的环境下,可在移动设备上直接访问桌面服务器。

  1. 找出本地的ip地址

    在 Mac OSX 系统的终端输入:

    ifconfig | grep "inet " | grep -v 127.0.0.1

    这将直接列出本地网址。设若为:192.168.5.15

  2. 在移动设备中访问本地服务器应用

    现在,若要访问桌面服务器上的:

    localhost/demos/touch-events.html

    则在移动设备的浏览器地址栏输入:

    192.168.5.15/demos/touch-events.html

若像笔者一样,习惯于使用 Safari 来开发、调试 Web 应用,这应该是最方便的真机调试方式了。

手势触控事件

手势触控事件种类

手势触控事件与鼠标事件很相似,共有4种,下面是鼠标事件与手势触控事件的对应关系表:

鼠标移动事件手势触控事件
mousedowntouchstart
mousemovetouchmove
mouseuptouchend
N/Atouchcancel

touchstart:手指或触控笔接触到设备面板上时被触发。此时将产生1或多个触控点 (touch point)。

touchmove:触控点在设备面板上移动时被触发。

touchend:手指或触控笔抬离设备面板时被触发。

touchcancel:当手势触控事件被取消时被触发。例如,当手指或触控笔从浏览器渲染区域滑动至浏览器菜单栏等。此时,因没有手指或触控笔抬离设备面板的动作,不能触发touchend事件,故而引入touchcancel事件以区别对待。

touchstart事件

下面代码,需在Chrome的响应式设计模式下、或在移动设备上运行才能看到结果。

当按下手指时,将触发一个touchstart事件。

let canvasBorderWidth; function initWebEvents() { canvas.addEventListener('touchstart', (evt) => { evt.preventDefault(); pc.groupStatic('evt-obj', 'evt'); pc.log(evt); pc.groupEnd(); pc.groupStatic('changed-touches', 'changed touches'); clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); for (let touch of evt.changedTouches) { pc.group('touch'); pc.log(touch); pc.group('type'); pc.log(touch.touchType); pc.groupEnd(); pc.group('identifier'); pc.log(touch.identifier); pc.groupEnd(); pc.group('client (x, y)'); pc.log(touch.clientX, touch.clientY); pc.groupEnd(); pc.group('computed offset (x, y)'); const {x, y} = getComputedOffset(touch.clientX, touch.clientY); pc.log(x, y); drawOnTouch(x, y); pc.groupEnd(); pc.groupEnd(); } ctx.restore(); pc.groupEnd(); pc.groupStatic('touches', 'touches'); for (let touch of evt.touches) { pc.log('identifier: %d', touch.identifier); } pc.groupEnd(); pc.groupStatic('target-touches', 'target touches'); for (let touch of evt.targetTouches) { pc.log('identifier: %d', touch.identifier); } pc.groupEnd(); }); } function drawOnTouch(offsetX, offsetY) { ctx.beginPath(); ctx.arc(offsetX, offsetY, 10, 0, Math.PI * 2, false); ctx.strokeStyle = '#ccc'; ctx.stroke(); ctx.fillStyle = 'gray'; ctx.fill(); } function calcCanvasBorderWidth() { let computedStyle = getComputedStyle(canvas); canvasBorderWidth = parseInt(computedStyle.borderWidth); } function getComputedOffset(clientX, clientY) { let bcr = canvas.getBoundingClientRect(); const {x, y} = bcr; return { x: Math.round(clientX - x) - canvasBorderWidth, y: Math.round(clientY - y) - canvasBorderWidth }; } initCanvas(); calcCanvasBorderWidth(); initWebEvents();

代码效果

在移动设备上,本例子的效果就像是按手印,使用1 - 4个手指同时按触屏幕,则canvas将如实地绘制出相应多个触控点。

如果按下2个手指时不是完全同步,将先后触发2touchstart事件,每个事件中只有一个手指印。

canvas中点按并滑动手指时,默认情况下,触控事件也会其它事件,例如,上下滚动等。因此在事件处理器代码中加入代码:

evt.preventDefault();

可避免这种副作用。

代码详析

TouchEvent

touchstart事件监听处理器中,参数evt的类型是TouchEvent。在鼠标事件中,我们可以直接从该参数中获取鼠标位置。但在触控事件中,因可能有多个触控点且多个触控点的位置、触控、离开屏幕的时间可能均不一样,因此鼠标位置信息较为复杂。TouchEvent封装了这些信息。

每个触控点的信息都封装在一个Touch对象中。参数evt3个属性可返回多个Touch对象集合。

touches:在屏幕上的一组触控点。

targetTouches:在触控事件监听目标上的一组触控点。

changedTouches:从targetTouches所记录的所有触控点中,再筛选出与不同触控事件相关的一组触控点。

  • touchstart事件中,当前激活的一组触控点。
  • touchmove事件中,当前移动的一组触控点。
  • touchend事件中,抬离屏幕或被接触元素的一组触控点。

因为changedTouches将众多的Touch对象进行了精细的分类,因此在不同的触控事件监听器中,我们一般使用evtchangedTouches来获取相应的一组触控点。

Touch

Touch封装了每个触控点的信息。它有几个重要的属性。

identifier:用于标识每个触控点的标识数字。Chrome中使用从0开始的数值来标识;而Safari使用很大的数字诸如1677645881等来标识。无论如何,对于每个不同的触控点,它们的identifier值是唯一的。

touchType:触控点类型。Safari中有此属性,其值为字符串direct;而Chrome无此属性。

3组鼠标坐标值属性。分别为clientXclientYpageXpageYscreenXscreenY。上面已阐述如何根据clientXclientY、或者根据pageXpageY来获取鼠标相对于canvas的偏移值以在canvas内相应的位置绘制图形。一般情况下,可优先使用clientXclientY属性值。

访问不同的触控点集合

同时按下2个手指,其中一个按在canvas内部,另一手指按在canvas外部,则可看到,evttouches记录了所有2个触控点,而targetTouches只记录了在监听目标canvas上产生的1个触控点。

changedTouches则从targetTouches中再进行进一步的筛选:在targetTouches所有1个触控点中,是哪些触控点导致了touchstart事件被触发?在这里是那个按在canvas上面的1个触控点。

再设想这样的场景:在canvas内部同时按下2个手指,然后,一个手指保持不动,仅移动一个手指。则touchstart事件将记录2个触控点,而touchmove事件仅记录移动的1个触控点。

代码结构

我们总追求高效的代码。canvas的边框宽度是相对固定的,加载网页完毕后一般不再改变,因此calcCanvasBorderWidth在处理触控事件之前调用一次就够了。但canvas在视口中的位置随着用户上下滚动网页而随时发生改变,因此getComputedOffset须在每一次的触控事件中调用。

touchmove事件

获取触控点信息

function initWebEvents() { canvas.addEventListener('touchstart', (evt) => { evt.preventDefault(); pc.groupStatic('touch-start', 'touchstart'); pc.group('changed touches'); clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); for (let touch of evt.changedTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); const {x, y} = getComputedOffset(touch.clientX, touch.clientY); drawOnTouch(x, y); pc.groupEnd(); } ctx.restore(); pc.groupEnd(); pc.group('target touches'); for (let touch of evt.targetTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); pc.groupEnd(); } pc.groupEnd(); pc.groupEnd(); }); canvas.addEventListener('touchmove', (evt) => { evt.preventDefault(); pc.groupStatic('touch-move', 'touchmove'); pc.group('changed touches'); clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); for (let touch of evt.changedTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); const {x, y} = getComputedOffset(touch.clientX, touch.clientY); drawOnTouch(x, y); pc.groupEnd(); } ctx.restore(); pc.groupEnd(); pc.group('target touches'); for (let touch of evt.targetTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); pc.groupEnd(); } pc.groupEnd(); pc.groupEnd(); }); } initCanvas(); calcCanvasBorderWidth(); initWebEvents();

当只有1个手指压在canvas上并移动时,4个分支上的touch的数量均为1

touchstarttouchmove
targetTouches11
changedTouches11

2个手指压在canvas上,但只移动1个手指,则touchmove事件中的changedTouches中的touch的数量为1,其余均为2

touchstarttouchmove
targetTouches22
changedTouches21

2个手指压在canvas上并同时移动时2个手指时,4个分支上的touch的数量均为2

touchstarttouchmove
targetTouches22
changedTouches22

计算缩放因子

在本节,当使用两个手指进行缩放时,计算出其缩放因子。

let touchLocs = {}; let prevDistance = -999; function initWebEvents() { canvas.addEventListener('touchstart', (evt) => { evt.preventDefault(); pc.groupStatic('touch-start', 'touchstart'); pc.group('changed touches'); clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); for (let touch of evt.changedTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); touchLocs[touch.identifier] = {x: touch.clientX, y: touch.clientY}; const {x, y} = getComputedOffset(touch.clientX, touch.clientY); drawOnTouch(x, y); pc.groupEnd(); } ctx.restore(); pc.groupEnd(); pc.groupEnd(); showAllTouchPoints(evt); }); canvas.addEventListener('touchmove', (evt) => { evt.preventDefault(); pc.groupStatic('touch-move', 'touchmove'); if (evt.targetTouches.length === 2) { pc.group('changed touches'); clearCanvas(); ctx.save(); ctx.translate(transformOffsetX, transformOffsetY); for (let touch of evt.changedTouches) { pc.group('touch'); pc.log('identifier: %d', touch.identifier); touchLocs[touch.identifier] = {x: touch.clientX, y: touch.clientY}; const {x, y} = getComputedOffset(touch.clientX, touch.clientY); drawOnTouch(x, y); pc.groupEnd(); } ctx.restore(); pc.groupEnd(); showAllTouchPoints(evt); pc.group('zoom factor'); if (prevDistance === -999) { prevDistance = calcDistance(); } else { let currDistance = calcDistance(); let offset = currDistance - prevDistance; pc.log(offset); prevDistance = currDistance; } pc.groupEnd(); } pc.groupEnd(); }); canvas.addEventListener('touchend', (evt) => { for (let touch of evt.changedTouches) { delete touchLocs[touch.identifier]; } showAllTouchPoints(evt); }); } function calcDistance() { let points = []; for (let propName in touchLocs) { const {x, y} = getComputedOffset(touchLocs[propName].x, touchLocs[propName].y); points.push({x, y}); } const [pt1, pt2] = points; return Math.sqrt(Math.pow((pt1.x - pt2.x), 2), Math.pow((pt1.y - pt2.y), 2)); } function showAllTouchPoints(evt) { pc.groupStatic('all-touch-points', 'all touch points'); for (let touch of evt.targetTouches) { pc.log('%d: (%d, %d)', touch.identifier, touch.clientX, touch.clientY); } pc.groupEnd(); } initCanvas(); calcCanvasBorderWidth(); initWebEvents();

总体思路是,在任一或多个触控点移动时,更新所移动的触控点的位置。然后计算两个触控点的距离,减去上一次两个触控点的距离,从而得出缩放因子。

我们需要跟踪两个触控点的位置,因此引入变量touchLocs。重点在于当按下、抬起时,总会产生新的触控点、消除相应触控点,触控点总处于一种频繁变换的状态。因此,当我们引入第三方变量来跟踪触控点时,须同时在touchstart, touchmovetouchend事件中均要更新第三方变量的状态。

而要判断当触控点在触控面板上移动时是否有且仅有两个触控点时,需在touchmove事件中根据evttargetTouches来进行判断。

在连续的移动事件中,事件响应频率各浏览器均有所差异,但均会及时、迅速。因此前后两次事件响应的状态变化值均很小,故每次计算出的缩放因子值都较小,这符合我们的日常体验。

参考资源

MDN

  1. TouchEvent
  2. Touch events
  3. Coordinate systems
  4. Using Touch Events

Apple

  1. Webkit JS
  2. Safari HTML5 Canvas Guide

Specifications

  1. Touch Events Specification
  2. CSSOM View Module

Others

  1. Add touch screen support to your website (The easy way)