NSStatusBar弹出框嵌套子弹出框时TextField异常及崩溃问题
问题描述
开发了一款在NSStatusBar弹出框中显示时区的Mac应用,原本运行正常,但在原弹出框内通过按钮添加第二个弹出框并加入TextField后出现问题:
- TextField显示已获得焦点但无法输入
- 反复切换第二个弹出框会导致崩溃
报错信息如下:
...as the first responder for window <_NSPopoverWindow: 0x7fb9c1807bb0>, but it is in a different window ((null))! This would eventually crash when the view is freed. The first responder will be set to nil.
已提取NSStatusBar配置和两个弹出框视图制作了可复现测试实例,其中使用EventMonitor捕获点击外部区域来关闭弹出框。开发环境为macOS 12.6.2,目标版本设置为12.0。
可复现测试代码
AppDelegate.swift
import Cocoa import SwiftUI @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { var popover: NSPopover! var statusBarItem: NSStatusItem! var eventMonitor: EventMonitor? var contentView = ContentView() func applicationDidFinishLaunching(_ aNotification: Notification) { // Create the popover let popover = NSPopover() popover.behavior = .transient popover.contentViewController = NSHostingController(rootView: contentView) self.popover = popover // Create the status item self.statusBarItem = NSStatusBar.system.statusItem(withLength: CGFloat(NSStatusItem.variableLength)) if let button = self.statusBarItem.button { button.image = NSImage(named: "Icon") button.action = #selector(togglePopover(_:)) } eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [unowned self] event in if self.popover.isShown { closePopover(event) } } eventMonitor?.start() NSApp.activate(ignoringOtherApps: true) } @objc func togglePopover(_ sender: AnyObject?) { if popover.isShown { closePopover(sender) } else { showPopover(sender) } } func showPopover(_ sender: AnyObject?) { if let button = statusBarItem.button { popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) self.popover.contentViewController?.view.window?.becomeKey() } eventMonitor?.start() } func closePopover(_ sender: AnyObject?) { popover.performClose(sender) eventMonitor?.stop() } } open class EventMonitor { fileprivate var monitor: AnyObject? fileprivate let mask: NSEvent.EventTypeMask fileprivate let handler: (NSEvent?) -> () public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> ()) { self.mask = mask self.handler = handler } deinit { stop() } open func start() { monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) as AnyObject? } open func stop() { if monitor != nil { NSEvent.removeMonitor(monitor!) monitor = nil } } }
ContentView.swift
import SwiftUI struct ContentView: View { @State private var showingPopover = false @State var value: String = "Initial Value" var body: some View { VStack { Button { showingPopover = true } label: { Image(systemName: "globe") .imageScale(.large) .foregroundColor(.accentColor) } .popover(isPresented: $showingPopover) { EditView(value: $value) } Text("Hello, world!") } .padding() } } struct EditView: View { @Binding var value: String var body: some View { VStack { TextField("Location ", text: $value) // the location as a string .multilineTextAlignment(.center) .lineLimit(1) .onSubmit { print("value submit \(value)") } } .padding() .frame(width:200, height: 50) } }
解决方案
问题根源在于全局事件监视器的逻辑缺陷和弹出框焦点管理冲突:全局监视器仅监听主弹出框状态,会错误干预子弹出框生命周期;手动设置主弹出框becomeKey()会破坏SwiftUI弹出框的响应者链,导致TextField无法正常输入。
1. 修复全局事件监视器逻辑
修改事件监视器的判断逻辑,同时检测点击是否在主/子弹出框内部,避免误关闭或残留响应者:
// 在AppDelegate的applicationDidFinishLaunching中替换eventMonitor初始化代码 eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [unowned self] event in // 检查点击是否在主弹出框内 let isInMainPopover = self.popover.isShown && self.popover.contentViewController?.view.window?.contains(event?.locationInWindow ?? .zero) ?? false // 检查点击是否在子弹出框内 let isInSubPopover = NSApplication.shared.windows.contains { window in guard let popoverWindow = window as? NSPopoverWindow else { return false } return popoverWindow.isVisible && popoverWindow.contains(event?.locationInWindow ?? .zero) } // 仅当点击在所有弹出框外部时,关闭弹出框 if !isInMainPopover && !isInSubPopover { if self.popover.isShown { closePopover(event) } self.contentView.showingPopover = false } }
同时调整弹出框关闭时的监视器生命周期管理:
// 修改closePopover方法 func closePopover(_ sender: AnyObject?) { popover.performClose(sender) // 移除原有的eventMonitor?.stop() } // 修改togglePopover方法 @objc func togglePopover(_ sender: AnyObject?) { if popover.isShown { closePopover(sender) eventMonitor?.stop() } else { showPopover(sender) eventMonitor?.start() } }
2. 移除强制焦点设置
删除showPopover方法中手动设置焦点的代码,交由SwiftUI自动管理弹出框焦点:
func showPopover(_ sender: AnyObject?) { if let button = statusBarItem.button { popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) // 移除该行:self.popover.contentViewController?.view.window?.becomeKey() } eventMonitor?.start() }
验证效果
- TextField可正常获取焦点并输入内容
- 反复打开/关闭子弹出框不会触发崩溃
- 点击外部区域可正确关闭所有弹出框
内容的提问来源于stack exchange,提问作者paulrbarnard
相关产品推荐
相关产品推荐

