指针事件

背景

  1. 鼠标是当下经典输入设备,但随着时代的发展,越来越多的输入设备出现了,如触控笔,触摸屏等等,我们可以为这些事件定义新的事件类型,但这样做是否真的有必要?不管是鼠标,或者是触控笔,其目的都是一样的,点选元素,而且一旦定义了新的事件类型,开发者就得为这个事件做兼容,让开发变得复杂。那么是否可以使用鼠标事件来兼容所有输入设备呢?这样做只会浏览器厂商带来困扰,这样要为所有新的输入设备来兼容鼠标事件。
  2. 为了解决这类问题,我们需要定义一类新的事件pointer,专门用作点选,可以是由鼠标造成的点击,也可以是触控笔带来的点击。开发者不需要考虑硬件设备如何,即可轻松编写代码。主要目的是为了提供一组标准化的事件和接口,允许开发者轻松地编写跨设备输入,同时也允许针对某一特定输入设备增加体验进行特殊处理。
  3. 另一个目标是让多线程的UA能够同时处理缩放、平移、点击等操作,互不影响。
  4. 指针事件有点像是鼠标事件,如pointerdown, pointermove, pointerup, pointerover, pointerout等,不仅具备了鼠标事件所有属性,还有新增的额外属性。

举个例子

  1. 检测是否支持指针事件: window.PointerEvent
  2. 检测触发指针事件的设备: event.pointerType: 'mouse' | 'pen' | 'touch' | other
  3. 获取点击区域的大小: event.width, event.height
  4. 手动创建指针事件: new PointerEvent(options)

PointerEvent interface

  1. pointerId: 指针触发事件的唯一标识符。
    • UA可以为鼠标保留0或1的通用指针id。
    • pointerId的值-1必须保留,用来表示该指针事件非指针设备所触发。
    • UA可以自行定义pointerId的分配规则。
    • 在顶级上下文中(即浏览器的一个tab页面内),pointerId必须唯一,如果出现指针从顶级上下文A移动到顶级上下文B的情况,UA必须给一个新的pointerId。
    • UA可以回收失效的pointerId
    • UA可以给某一特定指针设备重用相同的id,但如果该设备指针出现在新的会话上,则必须使用新的id
    • pointerId从0开始分配,但不能保证一定单调递增,有可能会回收失效的id用于下一个id赋值
  2. width: 指针在屏幕上占据几何面积的宽度,如果没有宽度(如鼠标)或者硬件不支持宽度,UA必须赋值1,该值在每一次指针事件发生时都可能变化,如手指按在屏幕上,用力按的过程中,几何面积会变大
  3. height: 高度,同上
  4. pressure: 归一化的压力值,[0, 1]区间的一个number
    1. 0表示没有压力,1表示最大的压力
    2. 如果硬件不支持,且当前指针事件处于活跃状态,则赋值0.1,如果是不活跃状态则是0
    3. 所有的pointerup事件的pressure都是0
  5. tangentialPressure: 归一化的切向压力值(可以理解为笔尖旋转),[-1, 1]区间的一个number
    1. 0表示没有任何切向压力
    2. 大部分设备只有[0, 1]的正值
    3. 如果硬件不支持切向压力,UA赋值0
  6. tiltX: YZ平面沿着y轴的旋转角度,[-90, 90]区间的一个number,往右旋转是正值。如果硬件不支持,UA赋值0。
  7. tiltY: XY平面沿着x轴的旋转角度,同上。
  8. twist: 传感器(触控笔),绕着其自身长轴的顺时针旋转角度,[0, 359]区间的一个number。如果硬件不支持,UA赋值0。
  9. altitudeAngle: 传感器的弧度,[0, PI / 2]区间的一个number,0表示平行于XY平面,PI/2表示垂直于XY平面。如果硬件不支持,UA赋值PI/2。
  10. azimuthAngle: 传感器方位角,[0, 2PI]区间的一个number,0表示沿着x轴正方向,PI/2表示沿着Y轴正方向。如果altitudeAngle为PI/2时,azimuthAngle永远是0。如果硬件不支持,UA赋值0。
  11. pointerType: 产生事件的指针类型(如鼠标,触控笔,触控屏)
    1. 鼠标: mouse, 触控笔: pen,触控屏: touch
    2. 如果指针设备不识别,则赋值空字符串
    3. 如果UA支持的指针设备大于以上三种,则需要给定不同的值,避免冲突
  12. isPrimary: 是否是主指针
  13. getCoalescedEvents(): 获取联合事件列表
  14. getPredictedEvents(): 获取预测事件列表
  15. 继承于MouseEvent,拥有其所有属性和方法

button状态

  1. 和弦式按钮交互: 在同一时刻按下多个按钮。有些指针设备,如触控笔、鼠标,支持多个按钮。在UIEvent规范中,鼠标事件是每次按下按钮都会触发mousedown和mouseup,为了更好的针对不同的硬件设备做抽象以及简化跨设备的输入编程,指针事件不会重复触发pointerdown和pointerup。
  2. 指针事件通过检查button和buttons的变化来检测和弦式按钮的情况,button和buttons都继承于MouseEvent。
  3. 以下关于button和buttons的定义只适用于指针事件,对于其他继承MouseEvent的事件,还是以UIEvent规范定义为准

button属性

  1. 为了让button state的标识能够支持所有指针事件,而不只是pointerdown和pointerup,button属性表示的是触发了状态变化而引发事件的那个按钮
  2. -1: 自上次事件以来,按钮和笔触(或触摸点)均未发生更改
  3. 0: 鼠标左键,触摸点,笔触点
  4. 1: 鼠标中键
  5. 2: 鼠标右键,笔筒按钮
  6. 3: 鼠标返回键
  7. 4: 鼠标前进键
  8. 5: 笔橡皮擦按钮
  9. 注:在鼠标拖拽的情况下,如果按下的是右键,pointermove的button属性是-1,而mousemove的button属性是2

buttons属性

  1. 是当前设备按钮的一个bitmask,与MouseEvent含义一致,但扩充了定义
  2. 0: 没有按钮被按下
  3. 1: 鼠标左键,触摸点,笔触点
  4. 4: 鼠标中键
  5. 2: 鼠标右键,笔筒按钮
  6. 8: 鼠标返回键
  7. 16: 鼠标前进键
  8. 32: 笔橡皮擦按钮

主指针

  1. 对应isPrimary为true的指针事件
  2. 对于每一种指针事件类型,任何一个时间点,只有一个主指针的事件
  3. 对于某一个特定的指针事件类型,第一个变活跃的指针就是该类型的主指针(如多指触摸,第一个触摸的指针事件就是主指针)
  4. 只有主指针会产生兼容鼠标事件,当有多个主指针存在时,这些指针都会产生兼容鼠标事件
  5. 如果开发者只关心一个点的指针事件,则不需要考虑主指针
  6. 如果UA同时有多个指针设备,则会产生多个主指针
  7. 某些设备或者操作系统,会忽略多个指针设备的情况,如果有一个指针设备出于激活状态,另一个则会失效,开发者无法控制这种情况
  8. 如果在多指触摸的情况下,第一个手指也就是主指针移出屏幕的情况下,UA依然可以为其他非主指针触发指针事件
  9. 当下的所有设备都不支持多鼠标输入,假设笔记本电脑上有一个触摸板,再外接一个鼠标,则这两个输入设备在移动鼠标时都被当成一个指针,也就是主指针

属性和默认行为

  1. pointerover: 可冒泡,可取消
  2. pointerenter: 不可冒泡,不可取消
  3. pointerdown: 可冒泡,可取消,当为主指针时,默认行为与mousedown相同,如果取消该事件,会导致兼容鼠标事件无法触发
  4. pointermove: 可冒泡,可取消,当为主指针时,默认行为与mousemove相同
  5. pointerup: 可冒泡,可取消,当为主指针时,默认行为与mouseup相同
  6. pointercancel: 可冒泡,不可取消
  7. pointerout: 可冒泡,可取消
  8. pointerleave: 不可冒泡,不可取消
  9. gotpointercapture: 可冒泡,不可取消
  10. lostpointercapture: 可冒泡,不可取消
  11. 注: 平移和缩放等视口操作,被认为是直接交互导致的,而非指针事件的默认行为,所以取消指针事件无法阻止平移和缩放操作,开发者可以使用touch-action的属性来显示的声明文档区域的直接操作行为。删除取消事件对这块的依赖有助于UA进行性能优化
  12. 注: 除了pointerenter和pointerleave外,其他事件的composed属性都为true。所有指针事件的detail属性都是0
  13. 注: 和鼠标事件一样,指针事件也有relatedTarget属性。当事件为pointerover和pointerenter时,该属性表示指针刚刚离开的元素;当事件为pointerout和pointerleave时,该属性表示指针刚进入的元素;其他事件该属性为null
  14. 注: 当一个元素接收到指针捕获时,该指针接下来的所有事件均在该元素边界内

pointerover

  1. 当指针移动到元素的命中测试边界时,会触发pointerover事件
  2. setPointerCapture()和releasePointerCapture()会改变命中测试目标
  3. 当指针被捕获的时候,我们始终认为它在元素边界内,以便可以触发边界事件
  4. 当UA不支持hover时,必须在pointerdown事件之前触发pointerover事件

pointerenter

  1. 当指针进入元素(或者其后代元素)的命中测试边界时,会触发pointerenter事件
  2. pointerenter与pointerover类似,但不冒泡

pointerdown

  1. 当指针进入激活按钮状态,会触发pointerdown事件
  2. 如果是鼠标,则是从没有没有按钮按下,转到至少有一个按钮按下
  3. 如果是触摸屏,则是当有物理接触时
  4. 如果是触控笔,分两种情况:
    1. 物理接触,且没有按钮按下
    2. 在hover状态下,从没有按钮按下,转到至少有一个按钮按下
  5. 综上可以看出pointerdown并非和mousedown一样会高频触发,和弦式按钮交互下情况就不一样了,pointerup也同理
  6. 针对不支持hover的设备,UA要先触发pointerover和pointerenter,然后再分发pointerdown
  7. 开发者可以通过取消pointerdown事件,来阻止某些兼容鼠标事件,但是无法阻止mouseover, mouseenter, mouseout, mouseleave事件

pointermove

  1. 当指针改变按钮状态的时候,会触发pointermove事件
  2. 当指针属性改变时,会触发pointermove事件,如coordinates, pressure, tangential pressure, tilt, twist, contact geometry (e.g. width and height)
  3. UA可以延迟触发pointermove事件,为了提高性能

pointerrawupdate

  1. 可以理解为高频率的pointermove事件,不可取消
  2. 只有在https的情况下才会触发该事件
  3. pointerrawupdate永远要比pointermove先触发
  4. 使用pointerrawupdate事件,会对性能造成影响,只有在对pointermove有高精度要求的时候才会使用,一般该情况下,js对该事件的回调函数要尽可能的快,可能不需要对其他指针事件去监听

pointerup

  1. 当指针脱离激活按钮状态时,会触发pointerup事件
  2. 如果是鼠标,则是从至少有一个按钮按下,到所有按钮都放开
  3. 如果是触摸屏,则是从有触摸,到完全没有接触
  4. 如果是触控笔,有两种情况:
    1. 触控笔没有按下任何按钮,且没有物理接触
    2. 触控笔在hover状态下,从至少有一个按钮被按下,到所有按钮都放开

pointercancel

  1. 以下情况会触发pointercancel事件:
    1. UA判断指针不可能在产生事件
      1. 指针在激活状态,设备屏幕旋转了
      2. 用户输入点超过了设备可支持的限制
      3. UA判断该触碰为误触,如: 硬件支持palm rejection
      4. UA判断该触碰为平移或者缩放
    2. 在触发了pointerdown事件之后,指针立刻被用用来操纵视口(如平移,缩放)
    3. 注: UA会通过指针事件触发平移和缩放等行为,因此平移和缩放的起始一定是pointercancel事件;如果要取消因此行为产生的pointercancel事件,可以使用touch-action的css属性
    4. 由于指针事件会触发拖拽,如果作为拖拽的初始动作,会触发pointercancel事件
  2. 在pointercancle事件之后,UA紧接着要触发pointerout, pointerleave事件

pointerout

  1. 以下情况会触发pointerout事件:
    1. 当指针设备移出了元素命中测试边界
    2. 对于不支持hover的设备来说,触发了pointerup事件之后,会触发pointerout
    3. 触发了pointercancel事件之后
    4. 当触控笔离开了可检测的悬停范围

pointerleave

  1. 当指针离开了某个元素以及其所有后代元素的命中测试边界,会触发pointerleave事件,包括来自不支持hover的设备的pointerup和pointercancel事件的结果
  2. 当触控笔离开了可检测的悬停范围,UA必须处罚pointerleave事件
  3. pointerleave和pointerout很像,但不可冒泡,且必须要离开其包含子元素所在范围的命中测试边界
  4. 效果类似于mouseleave,css中的hover

gotpointercapture

  1. 当元素捕获了指针时,会触发gotpointercapture事件
  2. 接下来会产生的事件的target都是该元素

lostpointercapture

  1. 当元素失去了指针时,会触发lostpointercapture事件
  2. 该事件在释放指针后,要比其他指针事件都优先触发
  3. target为失去指针的元素

click, auxclick, contextmenu

  1. 这三个事件本质都是PointerEvent,但是在事件分发过程中还是会按照其原始定义
  2. 除了pointerId,pointerType之外的所有属性(仅限于PointerEvent interface中定义的)都按默认值处理
  3. 如果事件由指针设备产生,则pointerId和pointerType按标准来赋值
  4. 如果事件由非指针设备产生,则pointerId为-1,pointerType为空字符串
  5. CSSOM View Module中重新定义了(screenX, screenY, pageX, pageY, clientX, clientY, x, y, offsetX, offsetY)这些属性都应该是双精度浮点数,但该定义只适用于PointerEvent,而不适用于MouseEvent。出于这个原因,UA在处理这三个事件的时候,需要对PointerEvent中的双精度浮点数值转为长整型(Math.floor)。

对Element interface的扩展

  1. setPointerCapture(pointerId): 给元素设定指定指针捕获,指针必须在激活按钮状态
  2. releasePointerCapture(pointerId): 给元素释放指定指针捕获
  3. hasPointerCapture(pointerId): 判断指定指针是否已经被元素捕获,返回bool。注: 此方法在setPointerCapture调用后调用,立刻返回true,不用等gotpointercapture事件触发。这样对pointerdown的监听事件中去检测隐式指针捕获有帮助。

GlobalEventHandlers的mixin

  1. 主要是on前缀的事件绑定,如: onpointerdown等

Navigator接口扩展

  1. maxTouchPoints: 最大触摸点数,如果一个设备有多个触摸屏,则以最大的为准
  2. 如果maxTouchPoints大于0,则表示用户设备支持触摸输入
  3. 最佳的交互是根据当前设备支持的最大触控点数,提供不同的UI交互,给不支持触控的提供更多按钮,给支持触控的提供更多触摸手势。

定义直接操作行为的候选区

  1. 前面的定义提到无法通过取消指针事件阻止视图操作行为(平移、缩放),用户如果需要定义哪种行为可以被允许,哪种行为应该被禁止,可以通过touch-action的css属性。
  2. 鉴于指针事件产生平移、缩放等行为都是基于触摸输入,在移动端,也可以通过触摸滚动页面,定义成touch-action也是历史原因,实际上并非仅指触摸,所有指针事件都适用。

touch-action

  1. 定义了直接操作交互(不仅限于touch)是否能触发UA的平移和滚动行为。
  2. 可选值: auto | none | [ [ pan-x | pan-left | pan-right ] || [ pan-y | pan-up | pan-down ] || pinch-zoom ] | manipulation
  3. 默认值: auto
  4. 当触发平移、缩放的时候,UA不在触发后续指针事件,为了阻止指针事件流,UA会触发一个pointercancel事件,有以下前提条件都需要满足:
    1. UA已经确定该操作是平移、缩放
    2. pointerdown事件已经发送给指针了
    3. pointerup或者pointercancel事件还未发送给指针
  5. 一些UA支持非常复杂的手势操作,但这些手势其实都可以拆解为单个手势的连续。举个例子,翻滚手势,用户一开始用手指快速移动做文档平移,然后离开屏幕,文档会继续惯性移动。如果文档还在移动中,用户会将手指再次放到屏幕上继续翻滚,或者阻止当前的平移使其减速,完全停止平移,或者改变平移的方向。标准文档没有定义这些后续手势,全部交由UA自己决定是否要给第二次的触摸触发指针事件。
  6. touch-action如果定义在iframe上,无法影响iframe文档自身。

确定支持的直接操作行为

  1. 如果元素的坐标空间内允许直接操作行为,则平移或者缩放交互符合元素的touch-action。需要注意的是,当发生了transform变化时,元素坐标空间可能与元素屏幕坐标不一致,如元素的x轴旋转90度之后与y轴平行,此时touch-action定义为pan-x则相对的实际上是pan-y的效果。
  2. 如果平移或缩放的直接操作行为,符合目标测试元素及其最近的拥有默认直接操作行为的祖先元素之间每个元素的touch-action,则支持
  3. 如果平移或者缩放已经开始了,且UA已经决定了该行为是否为直接操作行为,这个时候如果改变touch-action,对本次行为无效。例如:使用代码,在pointerdown回调函数上改变touch-action,从auto变成none,只要该指针处于活跃状态,就无法阻止本次输入产生平移或者缩放。
  4. 如果元素设置了pan-y,用户开始水平滑动手势,即使后面再改为垂直滑动,也不会产生垂直平移,因为手指一直在屏幕上,而UA已经决定该行为不是直接操作行为。
  5. 多指操作产生的平移和缩放不在本文档规范。

demos

<div style="touch-action: none;">
  该元素会接受所有来自直接操作行为的指针事件,因为无法产生任何平移或者缩放。
</div>

<div style="touch-action: pan-x;">
  该元素会接受所有来自非水平平移的直接操作行为的指针事件。
</div>

<div style="overflow: auto;">
  <div style="touch-action: none;">
    该元素会接受所有来自直接操作行为的指针事件,因为无法产生任何平移或者缩放。
  </div>
  <div>
    在该元素上的直接操作行为可能用于操作父元素(如果父元素可滚动或可缩放)。
  </div>
</div>

<div style="overflow: auto;">
  <div style="touch-action: pan-y;">
    <div style="touch-action: pan-x;">
      该元素会接受所有来自直接操作行为的指针事件,因为它只允许水平平移,而且其父元素只允许垂直位移。因此UA不会触发任何平移或缩放。
    </div>
  </div>
</div>

<div style="overflow: auto;">
  <div style="touch-action: pan-y pan-left;">
    <div style="touch-action: pan-x;">
      该元素接受来自所有非左平移的指针事件。
    </div>
  </div>
</div>

touch-action的值的详解

  1. auto: UA可以考虑在该元素上开始的所有平移或缩放的直接操作行为。
  2. none: 在该元素上开始的所有平移或缩放的直接操作行为都不会触发。
  3. pan-*: 如字面意思,在对应方向上平移(注: 该平移方向为文档平移,并非手指移动方向),UA可以考虑直接操作行为,其他情况都当做指针事件。
  4. pinch-zoom: UA只考虑捏缩
  5. manipulation: UA可以考虑在该元素上的所有平移和连续缩放(如:捏缩),但不会触发依赖于一定时间内多次激活产生的行为(如: 双击缩放,双击之后单指缩放)
  6. manipulation = pan-x + pan-y + pinch-zoom
  7. 注: touch-action目前在支持width和height的元素上生效(此限制旨在帮助UA优化低延迟的平移和缩放的直接操作);如果是span,可以给其加上display: block让其生效;未来可能会扩展到所有元素都生效
  8. 带方向的pan很实用,如在实现简单的pull-to-refresh操作时,在position为0时,将touch-action设置为pan-x pan-down,其他情况设置为pan-x pan-y;这样指针事件可以捕获向上的滚动,来做对应的刷新操作。
  9. 另一个应用场景是水平方向的图片轮播,初始可以设置元素的touch-action为pan-y,以便该轮播组件不影响垂直方向的平移;当播到最右边时,改为pan-y pan-right,让其继续往右滚动可以触发文档的滚动。在平移或者缩放过程中,无法更改其行为。
  10. 禁用某些直接操作行为,可以加速UA的响应,如默认auto的情况下,UA需要花费300ms的单击延迟响应双击操作,如果设置为none或者manipulation,则可以移除该延迟。

合并事件

  1. 出于性能原因,UA在指针属性变化时并不会立即触发pointermove或者pointerrawupdate,而是将几次变化合并成一个指针事件,通过getCoalescedEvents()方法可以获取原始的指针变化。
  2. 在绘图的时候,可以通过合并事件来绘制无锯齿的更更精细的曲线。
  3. 合并事件列表是按timeStamp的升序排列的,timestamp小于等于指针事件的timeStamp
  4. 如果是pointerdown事件导致的pointermove事件的合并,则UA必须先触发一个pointermove事件,合并事件的pointerId继承于pointerdown

Demo

预测事件

  1. UA根据现有指针的运动规律,可以预测未来指针移动的位置。
  2. 开发者可以使用预测事件来减少感知延迟
  3. pointerrawupdate事件的预测事件列表为空,但合并事件不为空
  4. 使用预测事件绘图: 1. 清空上一次预测事件的绘制;2. 绘制合并事件;3. 绘制当前预测事件

Demo

与鼠标事件的映射兼容

  1. 当今主流的web上下文都是监听鼠标事件,UA需要通过一定的算法来将指针事件转为鼠标事件。
  2. 兼容鼠标事件不包括click和contextmenu,即针对指针事件preventDefault无效。
  3. 开发者可以通过取消pointerdown事件来阻止兼容鼠标事件的产生。
  4. 只有指针被按下,才能阻止鼠标事件,即:如果只是悬停鼠标,无法阻止任何鼠标事件。
  5. mouseover, mouseout, mouseenter, mouseleave永远无法被阻止。
  6. 如果鼠标事件addEventListener加了passive: true的参数,也是无法被阻止的。

tailX/tailY与altitudeAngle/azimuthAngle之间的转换

  1. 这两组属性都可以表示指针设备在x-y平面的旋转角度,两组数据可以通过计算互相转换,由于硬件设备的不同,UA可能只能获取到其中一组数据,再通过转换得到另一组。

术语

  1. 激活按钮状态: buttons大于0,即指针设备至少有一个按钮被按下
  2. 激活指针: 任意可以产生事件的接触、触控笔、鼠标指针,或者其他指针。例如: 已连接的鼠标是激活指针、触摸屏的接触是激活指针、触控笔离开了数位板不是激活指针
  3. 命中测试: UA确定指针事件目标的过程。通常是通过对比指针位置与元素在屏幕上的视觉布局。
  4. 可测量属性: 如压力,位置等通过传感器发送来的数据,相反的如: pointerId, pointerType, isPrimary等