You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS15+下SwiftUI中实现动态UIContextMenu菜单与预览

实现iOS15+兼容的自定义预览ContextMenu,解决State变更不更新菜单问题

我需要实现一个支持自定义预览的.contextMenu修饰符,要求兼容iOS15.0及以上版本(iOS16.0+原生已支持该功能)。参考相关博客完成基础功能调整后,发现容器SwiftUI视图的@State变量变更无法触发菜单及预览内容更新。尝试用iOS15.0引入的UIDeferredMenuElement.uncached解决时,出现错误提示:

[UILog] Called -[UIContextMenuInteraction updateVisibleMenuWithBlock:] while no context menu is visible. This won't do anything

且菜单完全无法正常工作。现在想知道如何实现每次激活菜单时重建菜单与预览内容。

问题复现代码

struct TestOptions: OptionSet {
    let rawValue: UInt

    static let Test = TestOptions(rawValue: 1 << 0)
    static let Test0 = TestOptions(rawValue: 1 << 1)
    static let Test1 = TestOptions(rawValue: 1 << 2)

    static let all: TestOptions = [.Test, .Test0, .Test1]
}

// ContentView.swift
struct ContentView: View {
    @State private var option: TestOptions = .Test

    var body: some View {
        NavigationView {
            Text("选中的测试选项:\(option.rawValue)")
                .previewContextMenu(
                    preview: Text("\(option.rawValue)")
                ) {
                    PreviewContextAction(title: "Test", isSelected: self.option == .Test) {
                        self.option = .Test
                    }
                    
                    PreviewContextAction(title: "Test 0", isSelected: self.option == .Test0) {
                        self.option = .Test0
                    }
                    
                    PreviewContextAction(title: "Test 1", isSelected: self.option == .Test1) {
                        self.option = .Test1
                    }
                }
        }
    }
}

注:已修改PreviewContextAction代码以支持State选中标记。


解决方案

核心思路

问题根源在于自定义ContextMenu实现时,没有在菜单激活时重新捕获最新的State状态,而是复用了旧的快照。要解决这个问题,需要确保每次触发ContextMenu时,都基于当前最新的视图状态重新构建菜单内容和预览视图,避免提前缓存或错误调用更新方法。

具体实现步骤

  1. 修改自定义previewContextMenu修饰符的UIKit桥接逻辑
    在负责创建UIContextMenuInteraction的SwiftUI视图扩展中,将菜单和预览的构建逻辑延迟到菜单即将显示时执行:

    struct PreviewContextMenuModifier<Preview: View, Content: View>: ViewModifier {
        let preview: () -> Preview
        let menuContent: () -> Content
        
        func body(content: Content) -> some View {
            content
                .background(
                    ContextMenuUIKitBridge(
                        preview: preview,
                        menuContent: menuContent
                    )
                )
        }
    }
    
    private struct ContextMenuUIKitBridge<Preview: View, Content: View>: UIViewRepresentable {
        let preview: () -> Preview
        let menuContent: () -> Content
        
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            view.isUserInteractionEnabled = true
            let interaction = UIContextMenuInteraction(delegate: context.coordinator)
            view.addInteraction(interaction)
            return view
        }
        
        func updateUIView(_ uiView: UIView, context: Context) {}
        
        func makeCoordinator() -> Coordinator {
            Coordinator(preview: preview, menuContent: menuContent)
        }
        
        class Coordinator: NSObject, UIContextMenuInteractionDelegate {
            private let preview: () -> Preview
            private let menuContent: () -> Content
            
            init(preview: @escaping () -> Preview, menuContent: @escaping () -> Content) {
                self.preview = preview
                self.menuContent = menuContent
            }
            
            func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
                return UIContextMenuConfiguration(identifier: nil, previewProvider: {
                    // 每次预览显示时,基于当前State创建最新的预览视图控制器
                    let previewController = UIHostingController(rootView: self.preview())
                    previewController.preferredContentSize = CGSize(width: 300, height: 100)
                    return previewController
                }) { _ in
                    // 每次菜单激活时,重新构建UIMenu
                    let menuBuilder = UIHostingController(rootView: self.menuContent())
                    let menuItems = menuBuilder.rootView.children.compactMap { child -> UIAction? in
                        guard let action = child as? PreviewContextAction else { return nil }
                        return UIAction(title: action.title, state: action.isSelected ? .on : .off) { _ in
                            action.action()
                        }
                    }
                    return UIMenu(children: menuItems)
                }
            }
        }
    }
    
    // 扩展View,添加previewContextMenu方法
    extension View {
        func previewContextMenu<Preview: View, Content: View>(preview: @escaping () -> Preview, @ViewBuilder menuContent: @escaping () -> Content) -> some View {
            self.modifier(PreviewContextMenuModifier(preview: preview, menuContent: menuContent))
        }
    }
    
    // PreviewContextAction定义
    struct PreviewContextAction: View {
        let title: String
        let isSelected: Bool
        let action: () -> Void
        
        var body: some View {
            EmptyView() // 仅作为数据载体,实际菜单由UIKit桥接层构建
        }
    }
    
  2. 关键调整点

    • 将preview和menuContent改为逃逸闭包,确保每次调用时都能获取最新的State值
    • 在contextMenuInteraction(_:configurationForMenuAtLocation:)方法中,每次菜单激活时重新构建预览控制器和UIMenu,而非提前缓存
    • 移除任何调用updateVisibleMenuWithBlock的逻辑,该方法仅在菜单已显示时有效,我们的目标是每次激活时重建菜单,无需更新已显示的菜单

验证效果

修改后,每次长按触发ContextMenu时,都会基于当前最新的@State值构建预览视图和菜单选项的选中状态,解决了State变更不更新的问题,同时避免了错误日志的出现。


内容的提问来源于stack exchange,提问作者Baffo rasta

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 13:17:40