27

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
  • Well you're right after retesting the code I gave you, it doesn't work. I look kind of everywhere and there seem to be no way of doing this as I can't find a way to execute code when a menu is presented. I assume that apple want menu users to be aware that the menu doesn't act like an alert and that while it is displayed the element behind it are not disabled. I don''t think that ur problem is really going to be one from a users perspective anyway as they are used to it. Commented Feb 2, 2021 at 9:49
  • Thanks for re-testing. Probably, you are right. However, I don't fully agree with your hypothesis (or the approach Apple might have chosen here. Since they have implemented it differently themselves. E.g. when you open the files app and navigate to the "Browse" tab, you can open a menu by tapping on the three dots in the top right corner. Then, when tapping anywhere else on the screen, it doesn't trigger any of the buttons/menu items in the background but only closes the menu before the user can do anything else. And that is the behavior that I would expect as a user. Commented Feb 2, 2021 at 12:12
  • 1
    That is true. Honestly I don't know how they did it ! I know that it is not possible to display menus programmatically so you can't have any variable changing when the menu is displayed. The elements of the menu are rendered int he background so there is no correct on appear event. I also had a look at context menus which actually do work for you scenario. The drawback is that they require a long press and blur the background behind. So thats not how they did it as it is displayed on a simple tap and no blur in the files app. And it is not possible to display context menu on single tap... Commented Feb 2, 2021 at 19:32
  • 1
    I just wanted to add ehre that I have a menu in a ToolBarItem in the .navigationBarTrailling position and also a picker in the .principal position. Tapping outside of the menu over the picker DOES NOT fire the picker. But any tap outside the navigation bar DOES FIRE. Seems like an oversight regarding the screen at alrge, as Apple did see fit to stop taps on the navigationbar from firing. Commented May 20, 2021 at 13:53
  • 2
    I field a Bug Feedback to Apple Case: FB10033181 Commented May 31, 2022 at 20:04

6 Answers 6

13

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
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

2 Comments

Great workaround. One problem is that if the user long presses the menu button, there is no way to detect it. If there is please post a comment here. Thanks for this!
We're in May 2023 and we still have to use it. In my case, with 2 menus and 3 buttons on the same View it's very ugly!
4

There's actually a perfectly valid and almost non-hacky mechanism to achieve this. But for some reason, Apple just didn't care to mention the possibility of such an approach in the docs, or leave breadcrumbs at the very least.

So what you would do is create a custom ButtonStyle to add a hook which would observe the Menu's state changes:

struct MenuStateTrackingButtonStyle: ButtonStyle {
    @Binding var menuOpen: Bool
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) { _, newValue in
                withAnimation {
                    menuOpen = newValue
                }
            }
    }
}

Then you would create a menuOverlay to be able to actually react upon the user's gestures when Menu is open:

extension View {
    func menuOverlay(open: Bool, closeOnTap: @escaping () -> Void) -> some View {
        overlay {
            if open {
                Color.white
                    .opacity(0.001) // hack to make this view invisible yet valid for hit testing
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .onTapGesture {
                        closeOnTap()
                    }
            }
        }
    }
}

And use it like this:

@State private var isMenuOpen = false
...
var body: some View {
    contentView
        .menuOverlay(open: isMenuOpen) { isMenuOpen = false }
}

I would suggest to use this mechanism in the root view to account for nav bar, tab bar etc - ideally you'd probably want to disable the entire screen besides Menu when it's shown.

P.S. Not having to manually pass isMenuOpen as a Binding down the view hierarchy chain can be achieved with a few techniques, e.g. with environment values - utilizing custom EnvironmentKey with Binding as a value and passing it down via environment modifier, like so:

contentView.environment(\.menuOpen, $isMenuOpen)

We can thus optimize the solution to sth like that:

struct MenuStateTrackingButtonStyle: ButtonStyle {
    @Environment(\.menuOpen) var menuOpen
    ...
}

extension ButtonStyle where Self == MenuStateTrackingButtonStyle {
    static var menuStateTracking: MenuStateTrackingButtonStyle {
        MenuStateTrackingButtonStyle()
    }
}

And further simplify this mechanism usage by creating a dedicated Modifier

extension View {
    func menuDismissalOverlay() -> some View {
        modifier(MenuDismissalOverlayViewModifier())
    }
}

struct MenuDismissalOverlayViewModifier: ViewModifier {
    @State var isMenuOpen = false

    func body(content: Content) -> some View {
        content
            .overlay {
                if isMenuOpen { ... }
            }
            .environment(\.menuOpen, $isMenuOpen)
    }
}

Which on the client side will leave us to simply add

content
    ...
    .menuDismissalOverlay()

1 Comment

Could you add the code where you use the menu and this ButtonStyle?
1

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.

1 Comment

This is a nice hack though! I modified it slightly so I don't use the NSClassFromString, which might trigger app review probs - type(of: subview).description() == "_UIContextMenuContainerView"
1

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)
                }
            }
            .listStyle(.plain)
            .frame(height: 200)
            
            Spacer()
            Menu("Actions") {
                Button("Duplicate", action: { toggle.toggle() })
                Button("Rename", action: { toggle.toggle() })
                Button("Delete", action: { toggle.toggle() })
            }
            Spacer()
        }
    }
}

3 Comments

"Don't use a buttonStyle." vs. "In my app all buttons have a buttonStyle. " Sounds contradictory, a typo?
Well spotted. English is not my native language. I meant to say "all buttons had a style"
A part from that I should remove that "bonus tip" and comments on styles because I do use custom styles now without a problem. I just create structs that conform to ButtonStyle and apply them to all my buttons. It sounds like a bug that has been fixed since.
0

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.

4 Comments

Sigh I have to use the same hack. Filed a bug report to Apple, hopefully they fix this in iOS 16 or 17.
This is a real issue. Your solution assumes you can check if the menu is open or close, but that is the real solution! How to check if the menu is open or closed?
I'm not sure I understand what you mean by "your solution assumes you can check if the menu is open or closed". My, admittedly hacky, solution merely points out that via .onTap on the Menu, you do get a chance to at least know when a user is entering the menu. I then lay out potential routes for manually tracking when a user has exited the menu.
This solution with the onTap on the Menu sounds ok theoretically but I couldn't find a proper way to implement it. I also tried many different implementations with onAppear/onDisappear but there's always some situations where the events are not triggered (like when selecting the already selected element in the menu). Maybe the only way is the aggressive approach?
-1

I was able to resolve this by not using .onTapGesture and wrapping my List item within a Button. This can be further improved by converting to a view modifier.

List {
  Section {
    view.tappable {
      // do something
    }
  }
}

struct TappableModifier: ViewModifier {
    // use this over onTapGestures to resolve tap conflicts when dismissing context menus
    var action: () -> Void
    
    func body(content: Content) -> some View {
        Button {
            action()
        } label: {
            content
        }
    }
}

extension View {
    public func tappable(action: @escaping () -> Void) -> some View {
        ModifiedContent(content: self, modifier: TappableModifier(action: action))
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.