Issue #809

Add a hidden overlay UIContextMenuInteraction. Provide preview in previewProvider and actions in actionProvider. Use @ViewBuilder to make declaring preview easy.

extension View {
    func contextMenuWithPreview<Content: View>(
        actions: [UIAction],
        @ViewBuilder preview: @escaping () -> Content
    ) -> some View {
        self.overlay(
            InteractionView(
                preview: preview,
                menu: UIMenu(title: "", children: actions),
                didTapPreview: {}
            )
        )
    }
}

private struct InteractionView<Content: View>: UIViewRepresentable {
    @ViewBuilder let preview: () -> Content
    let menu: UIMenu
    let didTapPreview: () -> Void
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        let menuInteraction = UIContextMenuInteraction(delegate: context.coordinator)
        view.addInteraction(menuInteraction)
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) { }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(
            preview: preview(),
            menu: menu,
            didTapPreview: didTapPreview
        )
    }
    
    class Coordinator: NSObject, UIContextMenuInteractionDelegate {
        let preview: Content
        let menu: UIMenu
        let didTapPreview: () -> Void
        
        init(preview: Content, menu: UIMenu, didTapPreview: @escaping () -> Void) {
            self.preview = preview
            self.menu = menu
            self.didTapPreview = didTapPreview
        }
        
        func contextMenuInteraction(
            _ interaction: UIContextMenuInteraction,
            configurationForMenuAtLocation location: CGPoint
        ) -> UIContextMenuConfiguration? {
            UIContextMenuConfiguration(
                identifier: nil,
                previewProvider: { [weak self] () -> UIViewController? in
                    guard let self = self else { return nil }
                    return UIHostingController(rootView: self.preview)
                },
                actionProvider: { [weak self] _ in
                    guard let self = self else { return nil }
                    return self.menu
                }
            )
        }
        
        func contextMenuInteraction(
            _ interaction: UIContextMenuInteraction,
            willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
            animator: UIContextMenuInteractionCommitAnimating
        ) {
            animator.addCompletion(self.didTapPreview)
        }
    }
}

How to use it

struct ContentView: View {
    let deleteAction = UIAction(
        title: "Delete",
        image: UIImage(systemName: "delete.left"),
        identifier: nil,
        attributes: UIMenuElement.Attributes.destructive,
        handler: { _ in print("Deleted") }
    )

    var body: some View {
        NavigationView {
            Text("Hello, World!")
                .contextMenuWithPreview(actions: [deleteAction]) {
                    Text("Preview")
                        .border(Color.red)
                }
        }
    }
}

preferredContentSize

You can use GeometryReader in background to get SwiftUI View size, or get intrinsicContentSize in UIHostingController

private final class PreviewHostingController<Content: View>: UIHostingController<Content> {
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        if let window = view.window {
            preferredContentSize.width = window.frame.size.width - 32
        }
        
        let targetSize = view.intrinsicContentSize
        preferredContentSize.height = targetSize.height
    }
}

TargetedPreview

Since we add a dummy UIView as an overlay for our Preview, we should support targeted preview. First by taking snapshot of our SwiftUI View

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view

        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear

        let renderer = UIGraphicsImageRenderer(size: targetSize)

        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

Then construct UITargetedPreview

func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
    targedPreview
}

func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
) -> UITargetedPreview? {
    targedPreview
}

private var targedPreview: UITargetedPreview? {
    let parameters = UIPreviewParameters()
    parameters.backgroundColor = .clear
    return UITargetedPreview(view: UIImageView(image: snapshot), parameters: parameters)
}

Clear background color

Setting backgroundColor to .clear or any transparent color seems not allowed as UIContextMenu always shows a default white color.

view.backgroundColor = .clear

Read more