返回开发博客 踩坑实录

SwiftUI 的 LongPressGesture 在手指抬起之前就结束了

我们正在给 SharkTTY 的浮动键盘加一个按住说话的麦克风:按住即听写,实时预览识别结果, 松手停止,点击候选才插入终端。这个交互和对讲机一样古老,但它的 SwiftUI 实现里藏着一个陷阱—— 它躲过了我们的全部测试,差点让一个「根本不可能工作」的功能带着正常的外表发布出去: 录音每次都会在开始后三分之一秒自己停掉。这篇就是我们当初希望能搜到的那篇文章。

最顺手的 API,恰恰是错的

SwiftUI 看起来提供了按住录音需要的一切:

.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
    if !pressing { stopRecording() }   // ← 在 0.35 秒触发,而不是手指抬起时
} perform: {
    startRecording()
}

按大多数人的直觉去读——「pressing 告诉我手指是否按着,perform 在长按开始时触发」——完美。在 perform 里开始录音,pressing 变 false 时停止。

但方法签名没告诉你的是:LongPressGestureminimumDuration 一到就判定成功—— 而判定成功的手势,就是已经结束的手势。pressing 回调背后是 gesture state,手势一结束 gesture state 就复位。所以 pressing(false) 会在约 0.35 秒时送达, 此刻手指还牢牢按在屏幕上。你的停止逻辑紧跟着开始逻辑执行;而真正的手指抬起, 无论何时发生,已经没有任何人在听了。

在我们这里,破坏还格外安静:被掐断的录音只有约 0.35 秒,而我们「丢弃误触」的守卫会吞掉 300 毫秒以下的音频,词条静静恢复原状。没有报错、没有崩溃、没有半个结果—— 一个从未工作过的功能,披着一切正常的外衣。

为什么测试抓不到它

所有能测的都是绿的:录音会话的状态机有单测,音频管线正常,构建通过。缺陷完全住在 手势识别器的运行时语义里——哪个回调在哪个物理瞬间触发——而这恰好是单元测试 够不着、模拟器又懒得验证的一层。最后是代码审查抓住的,靠的是对每个回调问同一个问题: 「这段代码到底在哪个物理瞬间运行?」pressing 来说, 诚实的答案是:「不是你以为的那个瞬间。」

修法:在长按之后接一个拖动

标准模式——据我们所知也是唯一可靠的模式——是在 LongPressGesture 之后 顺接(sequence)一个零距离的 DragGesture。 长按判定成功后,组合手势交棒给拖动阶段,而拖动会让手势一直活到手指真正抬起:

.onTapGesture { switchToVoiceBoard() }   // 快速点按 = 另一个动作
.gesture(
    LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
        .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
        .onChanged { value in
            // 拖动阶段会反复触发;确保只启动一次。
            if case .second(true, _) = value, !isRecording { startRecording() }
        }
        .onEnded { value in
            // 只有长按成功后才会到达这里——这才是真正的手指抬起。
            if case .second = value { stopRecording() }
        }
)

这套组合成立靠三个性质。按住阶段无歧义: .second(true, _) 的含义是 「长按已成功且手指仍按着」。松手是真的:此时跑的是拖动,所以组合手势的 onEnded 在手指抬起时触发。 点按也干净:快速点按会让长按判定失败,序列永远到不了 .second,由独立的 onTapGesture 接管。

两个实践提醒。其一,按住期间手指的每次移动都会触发 onChanged,启动必须用自己的 标志位守住,保证恰好执行一次。其二,系统可能在不触发 onEnded 的情况下取消手势 (来电、按住时切后台),所以再挂一个 scenePhase 观察者,应用离开前台时直接取消录音。系好安全带,再加一根背带。

给下一个人留句警告

坏版本比对版本更短、更好读——这是最危险的一类 bug:未来某次重构看到这个 sequenced 组合,心想「这不就是一行 onLongPressGesture 的事」, 然后忠实地把缺陷复原回来。所以我们代码上方的注释不描述这段代码做了什么, 而是用一句话写明为什么更简单的写法是错的:LongPressGestureminimumDuration 到达时 成功并结束,此刻手指还按着,所以它的 pressing 回调不可能探测到松手。 如果一段代码的形状是被某个陷阱塑造的,就把陷阱写在代码旁边。

按住说话的键盘将随 SharkTTY 的下一次 iPhone / iPad 更新发布。你也踩过手势的坑, 或者发现这篇里的漏洞?反馈板随时开着。