返回

SwiftUI Menu 关闭事件检测及 VoiceOver 焦点处理

IOS

SwiftUI Menu 关闭事件检测:如何捕获菜单关闭

咱们直接说问题: 在 SwiftUI 里面用 Menu 组件,想知道啥时候这个菜单关掉了,然后做点事情,比如用 VoiceOver 的时候重新聚焦。看起来 SwiftUI 官方没给直接的方法,咋整?

一、 为什么要知道 Menu 啥时候关闭?

做可访问性(Accessibility)的时候,这个事儿挺重要的。用 VoiceOver 的时候,用户点开一个菜单,选了一项或者直接关掉菜单,焦点可能会跑到别的地方。为了让 VoiceOver 用户体验更好,咱得把焦点弄回菜单按钮上。

原帖里想用 UIAccessibility.post(notification: .layoutChanged, argument: self.menu),大方向没错。 问题是,咱不知道菜单啥时候关啊!

二、 几个能用的法子

1. 脏活累活:UIViewRepresentable + UIContextMenuInteraction

这是个绕弯的法子,思路是用 UIKit 里的东西来搞。UIKit 提供了更底层的控制,可以检测菜单交互的状态。

原理:

  1. UIViewRepresentable 把 UIKit 的 UIView 包装成 SwiftUI 能用的组件。
  2. 咱们创建一个自定义的 UIView,给它加上 UIContextMenuInteraction
  3. 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 的视图生命周期。 不一定百分百准, 但有些情况能用.

原理:

  1. Menulabel 里放一个不可见的视图 (比如 Color.clear)。
  2. 在这个不可见视图上用 .onDisappear。 菜单关掉的时候,这个视图也会消失,触发 onDisappear
  3. 使用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 的状态变化,然后通过反射去判断是不是你的菜单。非常不推荐, 只作为一种极端情况的探讨 .

原理:

  1. UIMenuController 是 UIKit 里控制菜单显示的。它有一个 menuWillHideNotification 通知。
  2. 咱们监听这个通知。
  3. (非常不推荐). 用 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
        }

    }
}

使用方法:

  1. 将上面代码复制到你的 SwiftUI 项目中。
  2. 在需要使用 Menu 的地方,用 ContentView 中的示例代码替换。

这个版本可以工作, 是通过增加isPresented 参数来解决. 它可以正确在voice over 模式, 将焦点设置到 menu 按钮上.