viedev
viedev

Reputation: 1527

SwiftUI: Notification when .contextMenu is dismissed (iOS)

I'm using .contextMenu together with .onDrag on a view and this seems to be very tricky:

The background color changes to gray by setting dragging to true. This is triggered by .onDrag which already happens when opening the context menu (a bit early but ok). When I use the button to close the menu I can set dragging to false. When I use the drag, the dragging state is changed back to false when the ItemProvider is deinitialized. So far so good.

The problem

When I tap outside the context menu to dismiss it, I seem to have no way to set the dragging state back to false. Adding .onDisappear to the Button in the menu does not work.

What am I doing wrong here? Any way I can get either get notified when the context menu closes or have the state change of dragging happen when the drag actually begins (so that the background is not immediately gray when the context menu is opened)?

Code below video.

enter image description here

struct ContentView: View {
    @State var dragging = false
    var body: some View {
        ZStack {
            Rectangle()
                .foregroundColor(.blue)
                .frame(width: 100, height: 100)
                .onDrag {
                    dragging = true
                    let provider =  ItemProvider(contentsOf: URL(string: "Test")!)!
                    provider.didEnd = {
                        DispatchQueue.main.async {
                            dragging = false
                        }
                    }
                    print("init ItemProvider")
                    return provider
                }
                .contextMenu {
                    Button("Close Menu") {
                        dragging = false
                    }
                }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(dragging ? Color.gray : Color.white)
    }
}

class ItemProvider: NSItemProvider {
    var didEnd: (() -> Void)?
    deinit {
        print("deinit ItemProvider")
        didEnd?()
    }
}

EDIT (Dec. 2022): It seems like the code works in iOS 16.2. I still haven't found a good solution to this for earlier iOS versions.

Upvotes: 7

Views: 1696

Answers (1)

lorem ipsum
lorem ipsum

Reputation: 29301

Since you mention UIKit, .contextMenu is a UIContextMenuInteraction

You can add a UIContextMenuInteraction to a SwiftUI View and have access to UIContextMenuInteractionDelegate to identify when the menu is dismissed.

SwiftUI View > ViewModifier > UIViewRepresentable > Coordinator/UIContextMenuInteractionDelegate

struct CustomContextMenuView: View {
    @State var dragging = false
    var body: some View {
        ZStack {
            Rectangle()
                .foregroundColor(.blue)
                .frame(width: 100, height: 100)
                .onDrag {
                    dragging = true
                    let provider =  ItemProvider(contentsOf: URL(string: "Test")!)!
                    provider.didEnd = {
                        DispatchQueue.main.async {
                            dragging = false
                        }
                    }
                    print("init ItemProvider")
                    return provider
                }
            //Use custom context menu and add actions as [UIAction]
                .contextMenu(actions: [
                    UIAction(title: "Close Menu", handler: { a in
                        print("Close Menu action")
                        dragging = false
                    })
                ], willEnd:  {
                    //Called when the menu is dismissed
                    print("willEnd/onDismiss")
                    dragging = false
                }, willDisplay:  {
                    //Called when the menu appears
                    print("willDisplay/onAppear")
                    
                })
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(dragging ? Color.gray : Color.white)
        
    }
}

The Menu can be implemented by pasting the code below in a .swift file and using it like the sample above.

extension View {
    func contextMenu(actions: [UIAction], willEnd: (() -> Void)? = nil, willDisplay: (() -> Void)? = nil) -> some View {
        modifier(ContextMenuViewModifier(actions: actions, willEnd: willEnd, willDisplay: willDisplay))
    }
}
struct ContextMenuViewModifier: ViewModifier {
    let actions: [UIAction]
    let willEnd: (() -> Void)?
    let willDisplay: (() -> Void)?
    
    func body(content: Content) -> some View {
        Interaction_UI(view: {content}, actions: actions, willEnd: willEnd, willDisplay: willDisplay)
            .fixedSize()
        
    }
}

struct Interaction_UI<Content2: View>: UIViewRepresentable{
    typealias UIViewControllerType = UIView
    @ViewBuilder var view: Content2
    let actions: [UIAction]
    let willEnd: (() -> Void)?
    let willDisplay: (() -> Void)?
    func makeCoordinator() -> Coordinator {
        return Coordinator(parent: self)
    }
    func makeUIView(context: Context) -> some UIView {
        
        let v = UIHostingController(rootView: view).view!
        context.coordinator.contextMenu = UIContextMenuInteraction(delegate: context.coordinator)
        v.addInteraction(context.coordinator.contextMenu!)
        return v
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }
    class Coordinator: NSObject,  UIContextMenuInteractionDelegate{
        var contextMenu: UIContextMenuInteraction!
        
        let parent: Interaction_UI
        
        init(parent: Interaction_UI) {
            self.parent = parent
        }
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
            UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [self]
                suggestedActions in
                
                return UIMenu(title: "", children: parent.actions)
            })
        }
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
            print(#function)
            parent.willDisplay?()
        }
        func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
            print(#function)
            parent.willEnd?()
            
        }
    }
}

Upvotes: 6

Related Questions