SwiftUI: How to ignore taps on background when menu is open?

Viewed 1316

I am currently struggling to resolve a SwiftUI issue:

In a very abstract way, this is how the code of my application looks like (not the actual code to simply things for the discussion here):

struct SwiftUIView: View {

    @State private var toggle: Bool = true

    var body: some View {
        VStack {
            Spacer()
            if toggle {
                Text("on")
            } else {
                Text("off")
            }
            Spacer()
            Rectangle()
                .frame(height: 200)
                .onTapGesture { toggle.toggle() }
            Spacer()
            Menu("Actions") {
                Button("Duplicate", action: { toggle.toggle() })
                Button("Rename", action: { toggle.toggle() })
                Button("Delete", action: { toggle.toggle() })
            }
            Spacer()
        }
    }
}

So what's the essence here?

  • There is an element (rectangle) in the background that reacts to tap input from the user
  • There is a menu that contains items that also carry out some action when tapped

Now, I am facing the following issue:

When opening the menu by tapping on "Actions" the menu opens up - so far so good. However, when I now decide that I don't want to trigger any of the actions contained in the menu, and tap somewhere on the background to close it, it can happen that I tap on the rectangle in the background. If I do so, the tap on the rectangle directly triggers the action defined in onTapGesture.

However, the desired behavior would be that when the menu is open, I can tap anywhere outside the menu to close it without triggering any other element.

Any idea how I could achieve this? Thanks!

(Let me know in the comments if further clarification is needed.)

5 Answers

You can implement an .overlay which is tappable and appears when you tap on the menu. Make it cover the whole screen, it gets ignored by the Menu. When tapping on the menu icon you can set a propertie to true. When tapping on the overlay or a menu item, set it back to false.

You can use place it in your root view and use a viewmodel with @Environment to access it from everywhere.

The only downside is, that you need to place isMenuOpen = false in every menu button.

Apple is using the unexpected behaviour itself, a.ex in the Wether app. However, I still think it's a bug and filed a report. (FB10033181)

enter image description here

@State var isMenuOpen: Bool = false

var body: some View {
    NavigationView{
        NavigationLink{
            ChildView()
        } label: {
            Text("Some NavigationLink")
                .padding()
        }
        .toolbar{
            ToolbarItem(placement: .navigationBarTrailing){
                Menu{
                    Button{
                        isMenuOpen = false
                    } label: {
                        Text("Some Action")
                    }
                } label: {
                    Image(systemName: "ellipsis.circle")
                }
                .onTapGesture {
                    isMenuOpen = true
                }
            }
        }
    }
    .overlay{
        if isMenuOpen {
            Color.white.opacity(0.001)
            .ignoresSafeArea()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .onTapGesture {
                isMenuOpen = false
            }
        }
    }
}

I think I have a solution, but it’s a hack… and it won’t work with the SwiftUI “App” lifecycle.

In your SceneDelegate, instead of creating a UIWindow use this HackedUIWindow subclass instead:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        let window = HackedWindow(windowScene: windowScene) // <-- here!
        window.rootViewController = UIHostingController(rootView: ContentView())
        self.window = window
        
        window.makeKeyAndVisible()
    }
}

class HackedUIWindow: UIWindow {
    
    override func didAddSubview(_ subview: UIView) {
        super.didAddSubview(subview)
        
        if type(of: subview) == NSClassFromString("_UIContextMenuContainerView") {
            if let rootView = self.rootViewController?.view {
                rootView.isUserInteractionEnabled = false
            }
        }
    }
    
    override func willRemoveSubview(_ subview: UIView) {
        super.willRemoveSubview(subview)
        
        if type(of: subview) == NSClassFromString("_UIContextMenuContainerView") {
            if let rootView = self.rootViewController?.view {
                rootView.isUserInteractionEnabled = true
            }
        }
    }
}

The subclass watches for subviews being added/removed, looking for one of type _UIContextMenuContainerView that’s used by context menus. When it sees one being added, it grabs the window’s root view and disables user interaction; when the context menu is removed, it re-enables user interaction.

This has worked in my testing but YMMV. It may also be wise to obfuscate the "_UIContextMenuContainerView" string so App Review doesn’t notice you referencing a private class.

It's not amazing, but you can manually track the menu's state with a @State var and set this to true in the .onTap for the Menu.

You can then apply .disabled(inMenu) to background elements as needed. But you need to ensure all exits out of the menu properly set the variable back to false. So that means a) any menu items' actions should set it back to false and b) taps outside the menu, incl. on areas that technically are "disabled" also need to switch it back to false.

There are a bunch of ways to achieve this, depending on your view hierarchy. The most aggressive approach (in terms of not missing a menu exit) might be to conditionally overlay a clear blocking view with an .onTap that sets inMenu back to false. This could however have Accessibility downsides. Optimally, of course, there would just be a way to directly bind to the menu's presentationMode or the treatment of surrounding taps could be configured on the Menu. In the meantime, the approach above has worked ok for me.

You can have the behavior you want by using a Form or a List instead of a "plain view". All buttons will then be disabled by default when the menu is on screen, but they need to be buttons, and only one per cell, it won't work with a tapGesture because what you are actually doing is tapping on the cell, and SwiftUI is disabling TableView taps for you.

The key elements to achieve this are:

  • Use a Form or a List
  • Use an actual Button. In your example you use a Rectangle with a tapGesture.

I modified the code you provided and if you open the menu you can't hit the button:

struct SwiftUIView: View {

    @State private var toggle: Bool = true

    var body: some View {
        VStack {
            Spacer()
            if toggle {
                Text("on")
            } else {
                Text("off")
            }
            Spacer()
            
            /// We add a `List` (this could go at whole screen level)
            List {
                /// We use a `Button` that has a `Rectangle`
                /// rather than a tapGesture
                Button {
                    toggle.toggle()
                } label: {
                    Rectangle()
                        .frame(height: 200)
                }
                /// Important: Never use `buttonStyle` or the
                /// default behavior for buttons will stop working
            }
            .listStyle(.plain)
            .frame(height: 200)
            
            Spacer()
            Menu("Actions") {
                Button("Duplicate", action: { toggle.toggle() })
                Button("Rename", action: { toggle.toggle() })
                Button("Delete", action: { toggle.toggle() })
            }
            Spacer()
        }
    }
}

Bonus:

  • Bonus: Don't use a buttonStyle. I lost so many hours of code because of this and I want to share it here too. In my app all buttons have a buttonStyle. It turns out that by using a style, you remove some of the behaviors you get by default (like the one we are discussing).

Instead of using a buttonStyle use an extension like this:

extension Button {
    func withRedButtonStyle() -> some View {
        self.foregroundColor(Color(UIColor.primary.excessiveRed))
            .font(Font(MontserratFont.regular.fontWithSize(14)))
    }
}

And add the withRedButtonStyle() at the end of the button.

In my case an alert was prevented from showing in a similar scenario, conflicting with the Menu as well as Datepicker. My workaround was using a slight delay with DispatchQueue.

   Rectangle()
    .frame(height: 200)
    .onTapGesture { 
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05){
           toggle.toggle()
        } 
     } 

The only real solution will happen when Apple fixes/refines SwiftUI regarding Menu (and Datepicker) behaviours.

Related