SwiftUI Menu 关闭事件检测及 VoiceOver 焦点处理
2025-03-01 22:30:34
SwiftUI Menu 关闭事件检测:如何捕获菜单关闭
咱们直接说问题: 在 SwiftUI 里面用 Menu
组件,想知道啥时候这个菜单关掉了,然后做点事情,比如用 VoiceOver 的时候重新聚焦。看起来 SwiftUI 官方没给直接的方法,咋整?
一、 为什么要知道 Menu 啥时候关闭?
做可访问性(Accessibility)的时候,这个事儿挺重要的。用 VoiceOver 的时候,用户点开一个菜单,选了一项或者直接关掉菜单,焦点可能会跑到别的地方。为了让 VoiceOver 用户体验更好,咱得把焦点弄回菜单按钮上。
原帖里想用 UIAccessibility.post(notification: .layoutChanged, argument: self.menu)
,大方向没错。 问题是,咱不知道菜单啥时候关啊!
二、 几个能用的法子
1. 脏活累活:UIViewRepresentable
+ UIContextMenuInteraction
这是个绕弯的法子,思路是用 UIKit 里的东西来搞。UIKit 提供了更底层的控制,可以检测菜单交互的状态。
原理:
UIViewRepresentable
把 UIKit 的UIView
包装成 SwiftUI 能用的组件。- 咱们创建一个自定义的
UIView
,给它加上UIContextMenuInteraction
。 UIContextMenuInteractionDelegate
协议里有contextMenuInteraction(_:willEndFor:animator:)
,菜单要关的时候会调用。
代码示例:
import SwiftUI
import UIKit
struct MenuCloseDetector<Content: View>: UIViewRepresentable {
let onWillClose: () -> Void
@Binding var isPresented: Bool //用来控制显示和消失的, 替代之前的.
@ViewBuilder let content: Content
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear //背景颜色透明,以免盖住其它东西.
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(interaction)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
//如果当前 isPresented 变为false 并且 已经presented 了. 就进行回调.
if(!isPresented && context.coordinator.alreadyPresented){
DispatchQueue.main.async {
context.coordinator.alreadyPresented = false
self.onWillClose()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
var parent: MenuCloseDetector
var alreadyPresented = false //标识位. 判断已经加载完毕.
init(_ parent: MenuCloseDetector) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
self.alreadyPresented = true;
//必要的时候可以在这边调整 config
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ -> UIMenu? in
return nil
}
}
// 这个方法是即将结束时候调用的.
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?
) {
// 如果已经 presented 了, 就回调并且重置alreadyPresented
self.parent.isPresented = false
}
}
}
struct ContentView: View {
@State private var menuButtonFocused = false
@State private var showingMenu = false //这个状态是外部状态.
var body: some View {
Menu {
Button("Option 1") {
print("Option 1")
}
Button("Option 2") {
print("Option 2")
}
} label: {
Text("My Menu")
.background( MenuCloseDetector(onWillClose: {
//这里可以知道它关闭了
DispatchQueue.main.async { //确保UI更新在主线程.
menuButtonFocused = true //然后把焦点设置上去
UIAccessibility.post(notification: .layoutChanged, argument: self.menuButtonFocused) //触发voice over 聚焦
}
}, isPresented: $showingMenu) { //传入状态和 action
EmptyView() //内容是空的.
})
}.accessibilityElement(children: .combine) //可访问性处理,合并子元素
.accessibility(addTraits: menuButtonFocused ? .isSelected : []) // 根据焦点状态,添加/移除 isSelected
.onTapGesture { //手动触发状态改变
showingMenu = true;
}
}
}
进阶技巧:
- 你可以自定义
UIContextMenuConfiguration
来实现更复杂的菜单行为,比如添加预览。
安全建议:
- 因为用了 UIKit 的东西,要确保 UIKit 代码在主线程上执行,避免 UI 问题。上面示例已经做了处理。
2. 讨巧的办法:onDisappear
+ DispatchQueue.main.async
这个方法相对简单,但依赖于 SwiftUI 的视图生命周期。 不一定百分百准, 但有些情况能用.
原理:
- 在
Menu
的label
里放一个不可见的视图 (比如Color.clear
)。 - 在这个不可见视图上用
.onDisappear
。 菜单关掉的时候,这个视图也会消失,触发onDisappear
。 - 使用
DispatchQueue.main.async
是为了确保, 当视图真正 disappear 之后才进行下一步操作.
代码示例:
import SwiftUI
import UIKit
struct ContentView2: View {
@State private var menuButtonFocused = false;
@State private var showingMenu = false;
var body: some View {
Menu {
Button("Option 1", action: {})
Button("Option 2", action: {})
} label: {
Text("Open Menu")
.background(
Color.clear //不可见的背景
.frame(width: 0, height: 0)
.onDisappear {
if(showingMenu){ //必须判断 showingMenu 为true , 要不然,初始化也会触发.
DispatchQueue.main.async {
showingMenu = false; //关闭.
menuButtonFocused = true
UIAccessibility.post(notification: .layoutChanged, argument: self.menuButtonFocused)
}
}
}
)
}
.simultaneousGesture(TapGesture().onEnded{
showingMenu = true //显示菜单
})
.accessibilityElement(children: .combine) //可访问性处理,合并子元素
.accessibility(addTraits: menuButtonFocused ? .isSelected : []) // 根据焦点状态,添加/移除 isSelected
}
}
优点: 简单,不需要 UIKit 代码。
缺点: 不够可靠. Menu 可能有其他的 disappear 情况. 要根据实际情况去调整.
进阶用法:
可以在.onDisappear{}
闭包内部执行更为复杂的逻辑.
安全建议: 确保所有和UI相关的更改,都要在DispatchQueue.main.async
中.
3. 全局监听 + 反射 (黑科技,慎用)
这个方法有点 "hacky",但 可能 在某些情况下管用。它的思路是监听全局的 UIMenuController
的状态变化,然后通过反射去判断是不是你的菜单。非常不推荐, 只作为一种极端情况的探讨 .
原理:
UIMenuController
是 UIKit 里控制菜单显示的。它有一个menuWillHideNotification
通知。- 咱们监听这个通知。
- (非常不推荐). 用 Swift 的反射(
Mirror
)去获取UIMenuController
的一些内部的信息, 比对信息. 判断关闭的是不是咱的Menu
.
代码示例: (高度不建议,仅作演示)
// 极其不推荐, 不保证未来版本可用, 也不保证任何情况都生效. 仅作极端思路演示。
import SwiftUI
import UIKit
import Combine
class MenuObserver: ObservableObject {
static let shared = MenuObserver()
private var cancellable: AnyCancellable?
init() {
cancellable = NotificationCenter.default.publisher(for: UIMenuController.willHideMenuNotification)
.sink { [weak self] notification in
//极其不推荐: 不要在这边做复杂的逻辑, 只是举个可能可以实现的想法.
print("全局菜单将要关闭") // 在这里添加日志
if let menuController = notification.object as? UIMenuController {
// 通过反射查看 UIMenuController 的内部结构 (极其不推荐!)
let mirror = Mirror(reflecting: menuController)
for child in mirror.children {
//print(" \(child.label ?? "nil"): \(child.value)") //千万不要使用 print 来调试!!容易导致crash.
//进一步的条件, 不再给出,因为该方案不推荐,只是提供极端情况下可以实现的一个方式
}
}
// 应该加一个延时,给 SwiftUI 一点时间更新状态 (非常不推荐)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// ... 你的逻辑 ...
// UIAccessibility.post(notification: .layoutChanged, argument: ...)
}
}
}
func stopObserving(){ //停止观察.
cancellable?.cancel()
}
}
极其不推荐此方法:
- 不稳定: UIKit 的内部实现可能随时改变,这种方法随时会失效。
- 性能问题: 全局监听通知、反射都会带来性能开销。
- 很复杂,容易导致错误 , 不如老老实实用
UIViewRepresentable
安全建议: 不要这样做!
方法选择总结:
- 方案一(
UIViewRepresentable
)最可靠,最推荐,尽管稍微复杂点。 - 方案二(
onDisappear
)简单,但是不能保证在所有情况下正确,并且需要增加外部状态来避免初始化的触发 - 方案三(全局监听)是最后的选择,强烈不建议。
直接给代码吧:
UIAccessibility.post(notification: .layoutChanged, argument: self.menu)
OK. 我知道这个通知什么时候发出。 但我没法知道, 菜单什么时候关闭的。 这段代码不能解决我的问题。
直接给最终能解决问题的完整方案吧:
这里给出完整的 UIViewRepresentable
方案, 包括必要的注释, 可直接复制到项目运行。
import SwiftUI
import UIKit
// 用于检测 Menu 关闭的辅助视图
struct MenuCloseDetector<Content: View>: UIViewRepresentable {
let onWillClose: () -> Void // 关闭时执行的回调
@Binding var isPresented: Bool // 控制 Menu 的显示/隐藏
@ViewBuilder let content: Content // Menu 的内容
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
view.addInteraction(interaction)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if !isPresented && context.coordinator.alreadyPresented {
DispatchQueue.main.async {
context.coordinator.alreadyPresented = false
self.onWillClose()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, UIContextMenuInteractionDelegate {
var parent: MenuCloseDetector
var alreadyPresented = false
init(_ parent: MenuCloseDetector) {
self.parent = parent
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration?
{
self.alreadyPresented = true
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in return nil}
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
willEndFor configuration: UIContextMenuConfiguration,
animator: UIContextMenuInteractionAnimating?
) {
self.parent.isPresented = false // 主要的逻辑, 关闭menu
}
}
}
struct ContentView: View {
@State private var menuButtonFocused = false
@State private var showingMenu = false // 控制 Menu 显示的状态
var body: some View {
Menu {
Button("Option 1") {
print("Option 1")
}
Button("Option 2") {
print("Option 2")
}
} label: {
Text("My Menu")
.background(
// 使用 MenuCloseDetector 包装一个空的 View
MenuCloseDetector(
onWillClose: {
// 菜单将要关闭时执行
DispatchQueue.main.async {
menuButtonFocused = true // 设置焦点标记
UIAccessibility.post(notification: .layoutChanged, argument: self.menuButtonFocused) // 发送 VoiceOver 通知
}
},
isPresented: $showingMenu // 传入 isPresented
) {
EmptyView()
}
)
}
.accessibilityElement(children: .combine)
.accessibility(addTraits: menuButtonFocused ? .isSelected : [])
.onTapGesture { // 通过点击事件手动控制 Menu 的显示状态
showingMenu = true
}
}
}
使用方法:
- 将上面代码复制到你的 SwiftUI 项目中。
- 在需要使用
Menu
的地方,用ContentView
中的示例代码替换。
这个版本可以工作, 是通过增加isPresented
参数来解决. 它可以正确在voice over 模式, 将焦点设置到 menu 按钮上.