
macOS 15 上 SwiftUI popover 里中文输入法选不了词——一次双 bug 排查
Zipic 用户在 macOS 15 上报了个怪 bug:popover 里改预设名,中文输入法按空格选不了词,我复现了,同时发现那一行的背景还会闪一下。但在 macOS 26 上却完全正常。顺着"背景闪一下"这条线索挖下去,竟挖出两个叠加的 bug——而 Apple 在 macOS 15 上的一处修复,恰好就把这个问题翻出来了。
最近收到 Zipic 用户反馈:在 macOS 15.5 上,给预设方案改名时,中文输入法选不了词——拼音敲进去能看到候选,但按空格那一下,候选词就是落不到输入框里。
复现了一遍,确实如此。更怪的是,按空格的同时,那一行 preset 的背景颜色会闪一下,像是被重新选中了一次。
这条"背景闪一下"的线索后来成了破案的钥匙。整个查错过程踩到的两个坑都很典型,单独看都不算冷门,叠加在一起却拼出了一个看似无解的怪 bug。
问题现象
复现路径很简单:偏好设置 → 预设方案 → 点某行的"编辑名称"按钮弹出 popover → 切到中文输入法 → 在 TextField 里输拼音 → 按空格选词。
预期是把候选词提交到输入框,实际是候选词消失、输入框空空,行背景闪一下。
一开始我顺着 @FocusState、popover 层级、IME marked-text 几个方向都怀疑过,换了几个写法没解决。后来在 macOS 26 上跑了一遍——完全正常。
这下问题反过来了:不是"我写错了什么",而是"15 和 26 之间,系统行为变了什么"。
顺着"背景闪一下"往下挖
预设行原本的代码大致是这样:
整行被一个外层 Button 包着,点行内空白处就能选中预设——挺常规的写法。
那"闪一下"意味着什么?意味着这个 Button 的 action 被触发了。换句话说:用户按下的那个空格,没去 TextField,而是把外层那个 Button 给点了一下。
去查 SwiftUI Button 在 macOS 15 上的行为变更,立刻找到了 Christian Tietze 的一篇文章 https://christiantietze.de/posts/2024/07/swiftui-button-click-through-fixed-macos-15/:从 macOS 15 Beta 2 起,SwiftUI 的 Button(包括 .plain 风格)跟原生 NSButton 行为对齐了——focus 在它上面时,按空格会激活它。
对绝大多数场景这是好事,但对"整行包成 Button"的写法就是灾难:popover 弹出后空格落回外层 Button 头上,preset 被"重新 select"了一次,所以背景闪一下。这是 Bug 一。
但只有 Bug 一还不够:popover 都弹出来了,里面 TextField 也写了 @FocusState,焦点为什么没接管过去?
第二个坑:popover 不会自己拿 key window
SwiftUI 的 .popover() 在 macOS 上是用 NSPopover 实现的,渲染在独立的 NSPopoverPanel 里。社区里有个流传很久的坑:这个 panel 不会可靠地自动 makeKey(),主窗口仍然是 key window。
后果是:IME 候选词浮窗是 OS 级别的、跟 key window 无关,所以"看上去候选词正常显示";但 IME 关联的 input context 仍在主窗口某个 control 上,不在 popover 里的 NSTextField 上;@FocusState 也不跨 NSWindow,panel 不是 key window 时它压根没法把 first responder 真正交给 popover 里的 TextField。于是用户按下的空格事件,被路由到了主窗口去——
正好撞上主窗口的 first responder——那行包了外层 Button 的 preset。Bug 一与 Bug 二合流,闪烁与无法 commit 同时出现。
macOS 26 不复现,是因为 26 重写了 SwiftUI 的容器/窗口层(Liquid Glass 配套),popover panel 的 makeKey 它内部接管了,Bug 二被消除,Bug 一单独不再触发可观察症状。
修复
定位到根因,修复就直接了:
第一,拆掉外层 Button。 整行点击改用 HStack + .contentShape(Rectangle()) + .onTapGesture,行为等价但不会被空格激活,顺手消除一处历史遗留的 .onTapGesture { /* prevent click-through */ }。
第二,强制 popover panel 成为 key window。 在 popover 内容里挂一个一次性 NSViewRepresentable,onAppear 时调一句 view.window?.makeKey()——这就是 macOS 26 替我们做的事,15.x 上手工补上。
第三,删掉 popover 周边按钮上的 .keyboardShortcut(.return) / .keyboardShortcut(.escape),让 TextField 自己的 .onSubmit 处理回车。keyboardShortcut 会让按钮成为 default/cancel button 参与 popover 内的按键分发,对 IME marked-text 阶段会有干扰。
第四,清掉项目里历史遗留的 KeyPressHandlingView——自己继承 NSView、实现 acceptsFirstResponder 和 keyDown 但不调 super.keyDown / interpretKeyEvents 的拦截器,是 IME 杀手,碰上中日韩输入法的 marked-text 流就会乱。
一点经验
写 macOS SwiftUI 的人多多少少都会遇到 popover + TextField + 焦点的奇怪问题。这次让我意识到的是:
当一个 bug 只在某个系统版本上出现、又在新版本上消失时,不要急着把它归为"系统 bug,等 Apple 修"。 真正发生的事,往往是新系统的某个修复把你代码里早就存在、但一直被掩盖的另一个 bug 翻了出来。
这个 case 里,"行背景闪一下"是用户多说的一句话,正是这句话把我从"IME 出问题了"的方向,拽回到"按键事件被路由到了不该去的地方"。如果用户只说"空格选词不行",我大概率会在 IME / @FocusState 一带绕很久。
诊断 bug 时,最不像问题的那个细节,往往是最关键的线索。

