手势识别
上一节你拿到了原料:pointerdown、pointermove、pointerup 一颗颗裸事件。可是没有人会对用户说「检测到一次 pointerdown 加 42 次 pointermove」——人类的世界里只有意图:点了一下、长按不放、拖走了、双指捏合放大——这些「手势」不是浏览器给你的,是你从一串事件流里「认」出来的。这一节讲的就是这门辨认学:怎么把离散的按下/抬起/移动翻译成连续的人类意图,怎么处理「按住不动是长按还是拖拽的前奏」这类天然歧义,以及当列表的滚动、画布的缩放、按钮的点击三方争夺同一根手指时,裁判权归谁。说白了,手势识别的本质是一台状态机加一本谈判手册——前者负责「听懂」,后者负责「摆平」。
一百多年前的电报房里,坐着一位老电报员。耳机里传来的不是文字,是无穷无尽的「滴、滴、哒、滴」——每个音都很短,单独听毫无意义。但老先生的耳朵里有一台隐形的机器:他把音与音之间的间隔分成三档——极短间隔拼成一个字母,稍长间隔分开两个字母,更长间隔分开两个单词。「滴哒」是 A,「哒滴滴滴」是 B,间隔本身就是语法。
手势识别干的就是这个活。手指在屏幕上留下的是无穷无尽的 pointerdown(滴)和 pointerup(哒),中间夹着 move 事件流;真正的语义藏在「间隔」和「节奏」里:按住 500 毫秒没动是长按,快速按下抬起是轻点,300 毫秒内又来一下是双击,按下后滑出 8 像素是拖拽的起跑信号。而且老电报员还有一条职场铁律:听不清的时候,宁可等下一拍,不能抢着翻——「滴」刚响,他不会立刻写 A,因为下一个音可能把它变成别的字。手势识别最难的一课也在这:歧义没消解前,谁都不许先动手。事件流是电码,手势是译文,识别器是那位耳朵里装着状态机、心里装着间隔语法的电报员。
术语对照:先把这一节的行话翻译成人话
| 术语 | 翻译成人话 | 电报房里的对应物 |
|---|---|---|
| 手势(gesture) | 一串事件流被认出的完整意图 | 一段电码翻译出的一个词 |
| 识别器(recognizer) | 负责辨认某一种手势的状态机 | 耳朵里那套「音与间隔」的规则 |
| 状态机(state machine) | 「当前状态 + 输入 → 新状态」的机器 | 译到一半的进度条 |
| 容差(slop / tolerance) | 「手指抖这点不算动」的豁免额度 | 音差半个音高照样算同一个音 |
| 歧义消解(disambiguation) | 等证据足够再决定是哪种手势 | 听不清先等下一拍,别抢翻 |
| 仲裁(arbitration) | 多个识别器抢一个手势时的裁判制度 | 两个译电员抢一份电报,值班主任裁决 |
| touch-action | CSS 声明「这块区域允许哪些默认手势」 | 频道分配表:这条线只收军情,那条收民用 |
| 速度追踪(velocity tracker) | 记录最近几帧速度、算出松手初速 | 记下发报节奏,判断对方是急是缓 |
| 惯性(inertia / fling) | 松手后按初速继续滚并逐渐衰减 | 译完最后一个词,笔尖的惯性还滑了半格 |
| 橡皮筋(rubber band) | 拉到边界还能拉出一点、松手弹回 | 纸卷到头还能拽出一截,松手回卷 |
手势不是事件:为什么浏览器只肯给原料
先回答一个初学者最常问的问题:浏览器为什么不直接发一个 tap 事件、一个 pinch 事件,非要我们自己认?三个理由。其一,语义无法穷举:点击、双击、长按、拖拽、轻扫、双指旋转、三指下滑、画圈、摇一摇……平台能内置的只是一小撮「公约数」,每个应用都有私有手势(手写签名、手势解锁的 Z 字、游戏里的搓招),浏览器不可能替你枚举。其二,歧义规则因应用而异:画布应用希望「按下立刻能画」,列表希望「按下先等会不会是滚动」,游戏希望「按下 200 毫秒内必须响应」——同一个动作序列,三家要三种解释权,统一发语义事件反而绑死手脚。其三,分层解耦:底层只管忠实地报「发生了什么物理事件」,语义层留给各平台各框架自己盖楼——Android 有 GestureDetector,iOS 有 UIGestureRecognizer,Web 有 Pointer Events + 你自己写的识别器。原料统一,菜各炒各的。这就好比菜市场只卖原料不炒菜:全世界的菜市场都供应同样的排骨和土豆(事件统一),但川菜馆做辣子鸡、粤菜馆做豉汁蒸(语义各家定义)——想吃什么菜,得自己下厨或请本帮厨师(识别器)。
状态机入门:一次「点击」的完整一生
所有识别器的心脏都是同一台机器:状态机——一组固定的状态,外加「在状态 X 收到输入 Y 就跳到状态 Z」的规则表。用它走一遍最简单的点击识别:
状态:IDLE(待命) → PENDING(按下,观察中) → 触发 / 取消
pointerdown:IDLE → PENDING,记下起点坐标和时间
pointerup :PENDING 时收到 → 移动距离 < 容差?→ 触发 tap!回到 IDLE
pointermove:PENDING 时收到 → 移动距离 > 容差?→ 判成拖拽,tap 取消
(其他任何意外输入:一律取消,回 IDLE)
注意两个设计细节。第一,触发永远是「事后」的:tap 在 pointerup 那一刻才算数——按下瞬间谁也不知道这是点击还是拖拽的开头,「确认」必须等抬起。第二,取消出口遍布全图:任何不符合预期的输入都让识别器优雅退场,不留半成品状态。这两个细节是所有手势识别的通用骨架:先攒证据,够判了再出手;判错了立刻干净退场。长按、双击、拖拽、捏合,全都是这台机器换张规则表的不同版本——本质上就是把 § 4.3 双指缩放那套「开台记账、离台销账」的单例扩展成一族。
长按:一个计时器引发的学问
长按(long press)是第二个必修手势,它的状态机多了一件武器:计时器。规则:pointerdown 时启动一个约 400~500 毫秒的定时器;期间若手指移动超过容差(防手抖)或提前抬起(那是普通点击),定时器作废;定时器到点且手指仍在原地——长按触发。iOS 的 UILongPressGestureRecognizer 默认 500 毫秒,Android 的 ViewConfiguration 默认 400 毫秒,双方不约而同选在这个量级,因为它是「用户主观上『按住不动了』」的心理阈值——再短会误触发,再长用户等得心焦。
let timer = null;
el.addEventListener('pointerdown', (e) => {
timer = setTimeout(() => 触发长按(e), 500);
});
el.addEventListener('pointermove', (e) => {
if (移动超过 8 像素 && timer) { clearTimeout(timer); timer = null; } // 抖动豁免用完了
});
el.addEventListener('pointerup', () => { clearTimeout(timer); timer = null; });
长按的经典并发症有两个。一是长按 vs 拖拽的抢跑:手指按下 300 毫秒时,识别器还不知道这是长按(该高亮)还是拖拽前奏(该跟手)——两套诉求只能等一个先出局。二是系统长按菜单的截胡:移动端浏览器在图片/文字上长按会弹系统的「拷贝/存储图像」菜单,还会高亮选中文本——应用自定义长按手势要配 CSS 静音:user-select: none(禁选中)、-webkit-touch-callout: none(禁系统呼出菜单),否则你的长按和系统的长按当场打架。好比考试里两道题共用同一张答题卡:你在第 3 栏写答案,系统监考也在第 3 栏盖章——想不串台,得先跟监考约定好(CSS 静音)这张卡归你。
拖拽与起拖阈值:8 像素的哲学
拖拽(drag / pan)看似最直白——按下、移动、松开——但工程上全靠一个不起眼的设定撑着:起拖阈值(drag slop)。手指按下时谁也不敢立刻开始拖:万一是点击呢?万一手只是微微一抖呢?于是规则定成:按下先不动,等位移超过阈值(移动端惯例约 8 像素,Android 的 ViewConfiguration 里有官方常量)才宣告「这是拖拽,起拖」——此后手指走多远,内容跟多远。这 8 像素买断了三样东西:点击不被抖动误伤、拖拽有明确的开场、以及最重要的——误触有豁免额度。人手指按在玻璃上不可能纹丝不动,没有这 8 像素的豁免,每次点击都会被判定成「微型拖拽」,整个界面的点击会时灵时不灵。
起拖之后是增量更新:每帧拿最新 pointermove 的位置减上一帧位置,得到增量,累加到内容位置上——不记录绝对坐标而记录增量,这样初始位置无论在哪,逻辑都不变。松手时的最后一件大事交给速度追踪器(velocity tracker):它一直在默默记录最近几帧的位移与时间,松手瞬间算出初速度,交给惯性系统——后面专节展开。
轻扫与拖拽的分家:速度才是判官
拖拽(drag)和轻扫(swipe / fling)的物理过程一模一样——按下、移动、抬起——说白了,分家靠的是松手那一刻的速度。判例:移动了 40 像素、松手速度每秒 900 像素,这是「甩」——判定为轻扫,内容按方向整页翻走或列表滚出惯性;移动了 400 像素、松手速度每秒 50 像素,这是「拖」——判定为拖拽,内容停在松手处。速度阈值是典型值每秒 500 像素上下(各平台自带常量),配上方向锁(主轴位移必须显著大于副轴,翻页手势才不被对角线误触发)。
轻扫的工程坑主要在误触:横向轮播上用户想纵向滚页面,手指划出一条 15 度的斜线——方向锁这时是唯一的护栏:判定主方向后立即「锁轴」,一旦锁给纵向,横向的轮播从此对这根手指装死。没有锁轴的轮播,用户每滚一下页面,轮播跟着横跳一格,堪称移动端最招人恨的 bug 之一。轻扫还常和区域绑定:从屏幕边缘内侧滑出是返回手势(要跟系统边缘手势划清界限,起点离边缘得留出一指宽),列表项上横向轻扫出「删除/归档」按钮(邮箱应用的经典交互)——同一个手势在不同区域有不同语义,管辖权按空间切分,这是歧义消解的第四种刀法。
双指几何学:pinch 与 rotate 的四个自由度
双指手势的识别,几何本质是把「两根手指」看成一个移动的坐标系:两指连线的中点是原点,距离是尺度,连线与水平线的夹角是朝向。于是从基准时刻到现在,坐标系的变化恰好分解成四个自由度:中点移动 = 平移,距离变化 = 缩放,夹角变化 = 旋转——加上「两指数量本身」这个开关,双指手势的全部词汇就这四个。§ 4.3 的缩放实现只用了「距离」一个自由度,完整的双指变换要三个一起算:以中点为锚缩放旋转、跟着中点平移,数学上就是一个仿射变换矩阵(§ 3.4 的 CSS transform 三件套:translate + scale + rotate)。识别上双指手势反而比单指简单——没有点击歧义:两根手指按下的瞬间,单击、长按、单击拖拽全部自动出局,裁判根本不用开庭。通俗地说,单指手势是「一词多义」的重灾区(同一个动作序列可能是五种意图),双指手势是「一词一义」的净土——这也是为什么专业绘图软件把「双指 = 画布导航」写成铁律:语义零冲突,手感零意外。
手势冲突:一根手指,两套诉求
现在把最难的话题摆上桌:冲突。真实界面里,同一根手指的事件流,往往同时喂给好几个识别器:列表的滚动识别器、列表项的点击识别器、内嵌滑块的拖拽识别器、长按的多选识别器——谁该赢?先看三场经典战役。
战役一:列表里的滑块。手指落在滑块上开始横向拖动,滑块说「这是我的拖拽」,列表说「这可能是我的滚动」。判法:方向锁——手指先动起来的主方向说了算:横向超过阈值而纵向没超,锁给滑块(此后列表纹丝不动);反之锁给列表(此后滑块再想拖也没机会)。§ 4.2 的 Android onInterceptTouchEvent 就是这场谈判的官方仲裁庭。
战役二:单击 vs 双击。第一次 tap 落地,可它也可能是双击的前半场——tap 识别器想立刻触发(响应快),double-tap 识别器想再等 300 毫秒(判断准)。鱼与熊掌的四种选法:延迟单击(等 300 毫秒,最准确但单击手感变肉)、立即触发+事后撤销(单击先上,双击确认后回滚单击效果再放大——地图应用的标准玩法:先缩放一半,双击确认后再弹到位)、分区治理(可双击的区域等、不可双击的区域立即)、换手势(双击改按钮,一了百了)。没有完美解,只有权衡——这跟点菜时「先上的凉菜可能吃不完」一个道理:先上(立即触发)吃得快但可能浪费(回滚动画),等齐了再上(延迟)体面但饿人。
战役三:长按 vs 滚动。列表项支持长按进入多选,但手指长按到一半微微下滑了一点——滚动识别器说「他要滚」,多选说「他在长按」。判法是时间优先+位移否决:长按计时器到点在先且位移没超阈值,多选赢;位移先超阈值,滚动赢。三场战役背后是同一条母法则:手势冲突的解药永远是「等」或「分」——等证据消歧义,或按区域/方向/优先级把管辖权提前分干净。
歧义消解三兵器:阈值、等待、仲裁
把上面散落的招式收进一个兵器谱,手势冲突的处理手段总共三件。兵器一:阈值(threshold)——距离阈值分拖拽与点击(8 像素),时间阈值分长按与轻点(500 毫秒),速度阈值分轻扫与拖拽(松手速度够快才算 fling)。阈值是「证据够了就判」,最便宜也最常用。兵器二:等待(delay)——证据天生不足时,用一小段等待换确定性:双击等 300 毫秒,滚动手势等主方向明确。等待的代价是响应延迟,所以每个等待都该问一句「这 300 毫秒用户等得起吗」——300 毫秒冤案(§ 4.3)的教训正在于此:全页面统一买单的等待,要用 touch-action 逐元素豁免。兵器三:仲裁(arbitration)——多个识别器同时在场时,用一套优先级规则决定谁有资格触发:先到先得(第一个达标的赢)、层级优先(外层视图先问,§ 4.2 的分发顺序)、或显式登记「A 必须先于 B 失败才轮到 B」(iOS 的 require(toFail:),双击就是这样硬性排在单击前面)。三件兵器从廉到贵排开:能靠阈值解决别用等待,能靠等待解决别动用仲裁——简单说,这跟医院分诊一个路数:先量体温(阈值),不放心就留观半小时(等待),还定不了就叫多科室会诊(仲裁)。
一台机器三种手势:通用识别器骨架
把点击、长按、拖拽装进同一台状态机——这是绝大多数手势库的心脏,值得整段读通:
class GestureMachine {
state = 'IDLE'; // IDLE → PENDING → TAP / LONGPRESS / DRAG → IDLE
down(e) {
this.state = 'PENDING'; // 按下:先观察,不定性
this.start = { x: e.clientX, y: e.clientY, t: performance.now() };
this.timer = setTimeout(() => {
if (this.state === 'PENDING' && this.位移() < 8)
{ this.state = 'LONGPRESS'; this.onLongPress(); } // 500ms 到点且静止
}, 500);
}
move(e) {
if (this.state === 'PENDING' && this.位移() >= 8) {
clearTimeout(this.timer);
this.state = 'DRAG'; // 位移破阈值:长按出局,拖拽起跑
}
if (this.state === 'DRAG') this.onDrag(e.clientX, e.clientY);
}
up(e) {
clearTimeout(this.timer);
if (this.state === 'PENDING') this.onTap(e); // 全程静止 + 抬起 = 点击
if (this.state === 'DRAG') this.onDragEnd(this.速度());
this.state = 'IDLE'; // 无论哪种结局,回炉重铸
}
cancel() { clearTimeout(this.timer); this.state = 'IDLE'; } // pointercancel 统一出口
}
读出四条骨架级的道理。第一,三种手势共享一个入口(pointerdown 后都是 PENDING),由谁先出局决定归属:位移破 8 像素,长按和点击同时出局;500 毫秒到点还静止,点击和拖拽出局;全程静止着抬起,只剩点击。所谓手势仲裁,在这台机器里就是三个条件比赛的跑道。第二,长按触发不等于终局:state 走到 LONGPRESS 后机器并没报废——长按后接着拖动(长按排序、长按拖拽文件的标准交互)只需要给 LONGPRESS 状态补一条「移动超阈值转 DRAG」的转移边,机器升级,架构不变。第三,出口纪律:up 和 cancel 都是清零出口,任何结局都回到 IDLE——上一节说的「每个入口配出口」,在这台机器上是字面意义的实现。第四,回调即接口:onTap / onLongPress / onDrag / onDragEnd 是机器对外的全部词汇,业务代码只填回调,不碰状态——识别与响应分层,机器就可以做成通用的库(Hammer.js、interact.js 这些知名手势库,解剖开来都是这台机器的豪华装修版)。
键盘也有手势:序列与组合的识别
手势识别并不专属于指针——键盘世界同样有「手势」,而且用的是同一台状态机,只是输入从 pointerdown 换成了 keydown。组合键(chord)是「同时按压」的手势:Ctrl+Shift+T,三个键同一时刻都在按位上——识别逻辑上一节讲过(keydown 里查修饰键字段),本质是即时判定,不需要状态。序列键(sequence)才是键盘版的真手势:vim 的 gg 跳到文件头、JetBrains 的连按两次 Shift 全局搜索、格斗游戏的 ↓↘→+拳 放波动拳——识别器必须记住最近的输入并等待后续,跟双击识别等第二击是同一个难题、同一副解药:开一个时间窗(典型 1 秒),窗内接上就续,窗口一过或来了不匹配的键,序列清零。
序列识别的两个工程细节。一是打断规则:vim 的 d 操作符之后接 d(删行)、接 w(删词)、接 j(删两行)——d 按下后机器进入「等待操作对象」的中间态,这个中间态在界面上要有暗示(vim 底部会显示 d,等用户补全),否则用户按了 d 想按 Esc 反悔,界面毫无反应,像对着没拨通的号码说话。二是输入缓冲(input buffer):格斗游戏在上一招的动画还没放完时就记录玩家的后续按键,动画一结束立刻接招——等待用户的操作永远比让用户等待体验好,宁可先把话记账上。键盘手势的状态机与指针手势完全同构:本质上就是把「位移超过阈值」换成「键位匹配期望序列」,把「500 毫秒计时器」换成「1 秒序列窗口」。学会了指针侧的机器,键盘侧免费赠送——这也为下一节铺路:键盘事件的目的地(焦点)和键盘手势的识别(序列),合起来才是「键盘用户的完整世界」。
touch-action:把管辖权写进 CSS
Web 平台处理「应用手势 vs 浏览器原生手势」冲突的钥匙,是 § 4.3 埋过伏笔的 CSS 属性 touch-action。它回答的问题是:这块元素上,哪些默认手势归浏览器,剩下的归你的代码。取值是一份管辖清单:auto(全都归浏览器)、pan-x / pan-y(横向/纵向滚动归浏览器,另一轴归你——轮播图的标准配置)、pinch-zoom(双指缩放归浏览器)、manipulation(滚动缩放归浏览器、双击缩放关闭——消灭 300 毫秒等待)、none(全归你的代码——画布、地图、游戏的配置)。
它的仲裁机制很硬核:浏览器在你第一次 pointerdown 之前就拿到了这份清单。手指移动后,如果走向匹配清单里归浏览器的手势(比如 touch-action: pan-y 的元素上纵向滑动),浏览器直接启动原生滚动,同时给你发 pointercancel——「这根手指归我了,你收摊」;如果不匹配(同一元素上横向滑动),浏览器不出手,pointer 事件流完整地交给你。于是管辖权冲突不再靠运行时打架,而是在样式表里提前划界。好比小区门禁的分道闸:快递车走闸机 A(浏览器原生滚动,走专用通道丝般顺滑),访客走闸机 B(应用手势,登记后通行)——闸机在来客之前就立好了,谁也不用到了门口再吵。
嵌套滚动:一页两台电梯的下行难题
touch-action 管的是「浏览器 vs 你」这一层,还有一层冲突它管不着:可滚动区域套可滚动区域。弹窗里塞一个长列表、聊天记录里嵌地图、横向卡片流放在纵向页面里——手指在内层滚到头,再继续划,该怎么办?默认行为叫滚动链(scroll chaining):内层到顶,滚动权自动上交给外层,整页跟着滚——弹窗里的列表滚完,弹窗背后的页面也跑了,用户找回原位要费半天劲。反过来,页面顶部的下拉刷新也住在这条链上:内层列表滚到顶再下拉,触发的是谁的刷新?
Web 的解药是另一个 CSS 属性 overscroll-behavior:默认 auto(链式上交),contain(内层到头就停,不再上交,弹窗列表的标准配置),none(到头不仅不上交,连浏览器的下拉刷新和边缘发光都一并关掉,聊天应用用它独占下拉刷新)。原生平台同一道题的标准答案叫嵌套滚动协商:Android 有 NestedScrollingParent / Child 接口让内外两层逐帧商量「这 12 像素归谁」,iOS 用 UIScrollView 的 panGestureRecognizer 优先级和 contentInsetAdjustment 处理同款问题——App 里那种「内层滚到头,外层丝滑接力」的手感,背后全是这套逐帧谈判,绝不是巧合。这就是一栋楼里的两台电梯:客人在 3 楼电梯按了下行没响应(内层到头),信号自动转给 1 楼总闸(链式上交);物业觉得扰民,给 3 楼电梯装了「到本层即停」(contain);还有人干脆把总闸的「停电自动下楼」功能也拆了(none)。滚动到头之后的一像素往哪去,每个成熟应用都得给出自己的答案。
原生手势层:浏览器和系统永远的第一优先权
touch-action 背后站着一条平台铁律:系统级手势永远优先。从屏幕顶下滑是通知中心,从底部上滑是回到主屏,边缘侧滑是返回——这些手势属于操作系统,你的应用既收不到事件也拦不住(最多能查询「这次滑动是不是要触发系统手势」好提前避让)。浏览器层同理:页面滚动、双指缩放、长按呼出菜单,都是浏览器原生手势,随时可以「征用」你的手指(表现为 pointercancel)。
这条铁律不是霸道,是用户利益:滚动是移动端最高频的操作,它的流畅度是整个系统的脸面——若每个网页都能拦住滚动等自己的代码表态(§ 4.2 的被动监听器一节见过这个死锁),全网的滚动手感一夜回到解放前。所以平台把「滚动权」收归国有,应用手势只能在国有频道之外开播。理解了这层,你面对「手势被劫走」的 bug 时排查方向就清晰了:先看 touch-action 有没有把这块区域划给浏览器,再看是不是撞上了系统手势区(屏幕边缘一指宽),最后才轮到查自己的识别器逻辑。
Android 认亲:GestureDetector 与分发仲裁
去两大原生平台看它们的官方解法。Android 的分工是「识别器 + 分发仲裁」两层。识别层是两个官方探测器:GestureDetector(管单击、双击、长按、轻扫——回调式 API,onSingleTapConfirmed 这个名字里的 confirmed 道尽了歧义消解哲学:双击等待期内它不发最终判决)和 ScaleGestureDetector(管双指缩放)。仲裁层就是 § 4.2 认过的 onInterceptTouchEvent:父视图(列表)在事件下行途中可以宣布「这单我劫了」,从此子视图(滑块)收不到后续事件——列表滚动的优先级就是这么保住的。子视图也有反制手段:滑块按下时调 requestDisallowInterceptTouchEvent(true),向父视图喊话「接下来的事件别拦,我接管了」——一次典型的「下级向上级申请管辖权」,列表与滑块的世纪难题在 Android 上的官方解法就是这对 API 的一来一回。
有意思的是,Android 把最常见的几条手势规则做成了官方常量:ViewConfiguration 里躺着 touchSlop(8dp 起拖阈值)、longPressTimeout(400 毫秒)、doubleTapTimeout(300 毫秒)——本节前面那些「行业惯例」的数字,在 Android 是系统统一发放的,全机所有应用手感一致。这种「手感国有化」的思路,值得每个平台设计者抄作业。
iOS 认亲:UIGestureRecognizer 的观察哨与谈判桌
iOS 把手势识别做成了完整的框架级公民:每个手势是一个 UIGestureRecognizer 对象,可以挂在任何视图上,像一个「观察哨」蹲在响应链(§ 4.2 认过亲)旁边,独立于视图的 touchesBegan 一族工作。它的识别过程是公开的状态机:possible(观察中)→ began → changed → ended / cancelled / failed——本节讲的状态机模型,在 iOS 是每个识别器自带的、可视化的官方属性。
精髓在谈判桌。多识别器共存的默认规则是「赢家通吃」:一个识别器触发,同视图的其他识别器收到 cancelled。需要和平共处时,走 delegate 谈判:gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) 返回 true,两个手势(比如双指缩放与双指旋转——§ 4.4 几何学那节说过它俩共享同一个双指坐标系)就能同时识别。还需要「让贤」时,用 require(toFail:):长按识别器登记「必须等双击识别器先宣告失败,我才有资格触发」——歧义消解的第三兵器(仲裁),在 iOS 被做成了显式 API。加上 cancelsTouchesInView 这个「识别成功后要不要连带取消视图的 touches 回调」的开关,iOS 的手势层几乎就是本节理论框架的官方实现——学完本节再看 UIKit 文档,会有「答案提前印在考卷上」的错觉。
惯性与橡皮筋:松手之后的世界
手势识别的终点(手指抬起)恰恰是「手感」的起点。第一件后事是惯性滚动(fling / momentum scrolling):速度追踪器记下松手瞬间的初速度(注意是最近几帧的速度,不是整段平均——用户抬手前往往减速,取平均会让飞出的劲头凭空缩水),内容按初速度继续运动,每帧乘一个摩擦系数(约 0.95)逐帧衰减,直到速度小到忽略不计。为什么所有平台的滚动列表都自带惯性?因为它模拟的是物理世界的「物体有质量」——纸卷在桌上一推,松手它还会滑。没有惯性的列表,用起来就像推一辆手刹没松的车:不是不能用,是每个动作都硌手。
第二件后事是橡皮筋(rubber banding):内容拖到边界后还能继续拖出一段,但位移打折(阻力随拉伸递增),松手弹回原位。它公然违反物理(世界上没有越拉越重再弹回的纸卷),却是 iOS 当年封神的交互发明——它用「拉不动但有反馈」替代了「戛然而止的死墙」,把「到头了」这个信号翻译成了身体能感知的语言。阻尼系数的调校是手感玄学的重灾区:拉出多少、弹回多快、 overshoot 要不要回弹一点点——差之毫厘,「 Q 弹」和「肉」隔着一个版本号。这两件后事连同 60fps 的渲染纪律,共同构成了「这个 App 手感真好」的全部秘密——用户说不出来,但手指记得。
触觉反馈:给手势装上回手
纯视觉的手势反馈永远慢半拍——眼睛看动画要几百毫秒,皮肤感知只要几十毫秒。所以成熟的手势系统都配了触觉反馈(haptics):列表滚到分区边界「哒」一下、长按触发时沉沉一震、双指缩放跨过整数倍时的细微 tick、打字键盘的轻点——这些细微的震动是手势的「回手」,告诉手指「事情成了」。iOS 把它做成了体系:UIImpactFeedbackGenerator 的 light / medium / heavy 三档冲击,加上 selectionChanged 这种「拨盘刻度」专用反馈;Web 侧有简版 navigator.vibrate(20)(Android 支持,iOS Safari 出于滥用顾虑一直没开放)。
触觉设计的纪律跟音效一样:宁缺毋滥。每一次震动都要对应一个「值得通知手指」的时刻——阈值跨越、边界到达、模式切换;把震动当点击音效乱用,用户先是起鸡皮疙瘩,然后默默关掉你的应用通知。理想的手感是「手指先于眼睛知道结果」:刻度拨轮滚过一档,指尖先「嗒」,眼睛随后确认——手机键盘打字比外接键盘更「跟手」的错觉,一半功劳在这块每秒几十次的震动马达上。相当于开车时的路面反馈:方向盘传来的每一丝颠簸都不是噪音,是轮胎在向手汇报路况——没有这些汇报,开车就变成了玩游戏机。
手势设计的身体工学:拇指才是第一公民
收尾前上一节设计课——手势不是画在 Figma 里的线,是长在人手上的约束。目标尺寸:可点目标对角线不小于 9~10 毫米(约 44pt/44dp),因为指腹接触面积远大于鼠标指针——「指尖上的目标」要按指腹算,不按像素算。拇指热区:单手握持时拇指自然覆盖的是屏幕下半部偏持机手一侧,顶部边角是无人区——把关键操作放拇指够得着的地方,是移动端布局的第一原则。边缘误触:贴近屏幕边缘一指宽是系统手势保留区,应用自己的边缘手势(侧滑返回、边缘抽屉)要给系统让位或做防误触延迟。左右手:习惯差异真实存在,重要手势尽量做镜像或放中间。最后一条最重要:每个手势都要有「不会手势也能活」的替代路径——按钮、菜单、键盘。手势是快捷键,不是唯一入口;只会手势的界面,等于只教了一遍答案就闭卷的考试,考生(用户)第二次进考场就懵了。这条原则通往 § 4.6 无障碍——那里会把它展开成法律级的硬要求。
排错手册:手势三案
- 案情一:自定义拖拽/画布手势,手指一动就被「劫走」,pointercancel 降临。管辖权被浏览器收走了:查 touch-action——画布地图类区域应设 none,轮播设 pan-y;再看元素是否贴着屏幕边缘(系统手势保留区)。
- 案情二:单击和双击打架——双击时先触发了两次单击。没有歧义消解:可双击区域的 tap 要么延迟 300 毫秒等 double-tap 出局,要么走「立即触发+确认后回滚」的地图式方案;或干脆给双击换按钮入口。
- 案情三:长按手势时灵时不灵,偶尔还弹系统菜单。三查:容差是否太小(手抖被判成拖拽,把 slop 放宽到 8 像素);定时器是否没在 move 超阈/pointerup 时清理(状态残留);图片文字上是否没禁系统长按菜单(user-select: none + -webkit-touch-callout: none)。
指针事件是耳机里无穷的滴滴哒哒,手势是译文,识别器是耳朵里各揣一张规则表的译电员:tap 译电员等一次干净的「滴哒」间隔,长按译电员掐着 500 毫秒的秒表,拖拽译电员盯着 8 像素的容差刻度,双指译电员两人一组量距离和角度。他们共用一条铁律:听不清,先等下一拍,不许抢翻(歧义消解)——「滴」刚响就写 A,下一个音来了就得涂改。
抢电报的事天天发生(手势冲突):单击译电员和双击译电员盯同一串码,解法是兵器谱三件——阈值量证据(够判就判)、等待换确定(300 毫秒留观)、仲裁分输赢(require to fail,让贤制度)。跟系统台的关系(浏览器原生手势、系统边缘手势)是频道分配表(touch-action):军情专线归台里(滚动归浏览器),民用频道归译电员——表提前贴好,不用到时候抢。
松手不是下班:惯性(松手后按初速衰减地滑)和橡皮筋(拉到头还能拽一截弹回来)是译文之后的余韵,「手感」两字全在这半秒钟里。两大原生台是这门手艺的老字号:Android 发官方秒表和刻度尺(ViewConfiguration 常量)+ 值班主任仲裁(onInterceptTouchEvent),iOS 干脆给每个译电员配了公开的进度条(possible → began → changed)和谈判桌(simultaneous / require toFail)。
最后记住身体工学这条底线:拇指够得着的地方才放关键操作,44 像素是目标的及格线,而每个手势都得有「不会手势也能活」的按钮备份——译文再漂亮,也得给看不懂电码的人留一扇正门。
写手势代码前过一遍:
① 识别器是状态机:每个入口配出口,pointerup 和 pointercancel 共用收摊函数,不留幽灵状态;
② 起拖阈值 8 像素、长按 500 毫秒、双击窗口 300 毫秒——这些行业数字直接用,别自创玄学参数;
③ 单击双击共存区先想清楚歧义策略:延迟、触发后回滚、分区、换按钮,四选一,别裸奔;
④ 自定义手势区域设置 touch-action(画布 none、轮播 pan-y),管辖权在 CSS 里划界,别在 JS 里打架;
⑤ 长按要配 user-select: none 和 -webkit-touch-callout: none,跟系统菜单抢地盘没有赢家;
⑥ 惯性用「最近几帧速度」当初速,衰减系数 0.95 附近起步,橡皮筋给边界留呼吸;
⑦ 手势目标 ≥ 44 像素,关键操作落在拇指热区,边缘给系统手势让路;
⑧ 每个手势配一个按钮或菜单的替代入口——手势是快车道,不是唯一门。
手势识别一句话:手势不是事件,是从事件流里认出来的意图——识别器是一台状态机,冲突的解药是阈值、等待与仲裁三件兵器。状态机的骨架是「先攒证据够判再出手,判错立刻干净退场」:tap 等 pointerup 结算,长按靠 500 毫秒计时器加 8 像素容差,拖拽用起拖阈值买断点击与抖动,双指手势是「中点-距离-角度」坐标系的变化,天然没有歧义。冲突三战役(列表 vs 滑块的方向锁、单击 vs 双击的等待或回滚、长按 vs 滚动的位移否决)背后是同一条母法则:等证据消歧义,或提前分管辖。Web 侧的管辖分界线写在 CSS(touch-action:pan-y 给滚动、none 给画布),系统级手势(边缘、通知中心)永远第一优先,pointercancel 就是征用令。两大原生平台是这门手艺的老字号:Android 发官方阈值常量(ViewConfiguration)加分发仲裁(onInterceptTouchEvent + requestDisallow),iOS 给每个识别器配公开状态机(possible → began → changed)加谈判桌(simultaneous / require toFail)。松手之后还有三件后事:惯性(最近几帧速度起算、0.95 衰减)、橡皮筋(边界弹回)与触觉反馈(震动马达是手势的回手,「手感」全在这半秒);轻扫与拖拽靠松手速度分家(500px/s 门槛 + 方向锁),嵌套滚动靠 overscroll-behavior 断链(contain 弹窗标准配、none 独占下拉刷新)。身体工学收尾:44 像素及格线、拇指热区、每个手势配替代入口。下一节转向键盘用户的全世界——焦点怎么走、Tab 顺序怎么排、焦点陷阱怎么防,让不用鼠标的人也能把界面用到底。