TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

SOLVED: Custom Popup menu won't close itself on one specific action

Forums > SwiftUI

Greetings,

I need your brilliant minds here, as I've been trying anything and everything, to no avail.

I've followed this "tutorial" to create a custom TabView with a PopUp menu, and after some time and effort, managed to get it working as I needed, except for one thing: tapping on one of the menu of the PopUp menu should close said PopUp menu. As long as the menu does nothing, tapping on it closes the menu, but if I assign an action to it, then it stays open. See demo here for details:

As you can see, tapping on the + button opens the menu and tapping again closes it. When I tap on Log Weight, which has no action assigned, it closes as well. Navigating to another tab menu also closes the menu. It's only when I tap on Add Measure that it does not close itself. The new fullscreen sheet is presented, but on dismiss, the PoPup menu is still open.

To make sure it was not my custom implementation or something like that, I've created a new project just to replicate the issue and still happens. Here is all the code for review. You may copy/paste it on your end, if you wish/need to.

ContentView:

import SwiftUI

struct ContentView: View {

    @AppStorage("themeIndex") private var themeIndex:Int = 2

    @State private var showMenu = false
    @ObservedObject var router = ViewRouterTabBarView()

    var body: some View {
        ZStack(alignment: .bottom) {
            VStack {
                Spacer()

                router.view

                Spacer()
                HStack {
                    TabItem(viewModel: .home, router: router, showMenu: $showMenu)
                    TabItem(viewModel: .history, router: router, showMenu: $showMenu)

                    TabPlusIcon(showMenu: $showMenu)
                        .onTapGesture {
                            withAnimation {
                                showMenu.toggle()
                            }
                        }

                    TabItem(viewModel: .charts, router: router, showMenu: $showMenu)
                    TabItem(viewModel: .profile, router: router, showMenu: $showMenu)
                }
                .frame(height: UIScreen.main.bounds.height / 10)
                .frame(maxWidth: .infinity)
            }
            if showMenu {
                PopUpMenu()
                    .padding(.bottom, 134)
                    .zIndex(1)
                    .onTapGesture {
                        withAnimation {
                            showMenu.toggle()
                        }
                    }
            }
        }
        .ignoresSafeArea()
    }
}

struct TabPlusIcon: View {
    @Binding var showMenu: Bool

    @State private var shadowOpacity: CGFloat = 0.8
    @State private var shadowRadius: CGFloat = 7

    var body: some View {

        ZStack {
            Circle()
                .foregroundColor(.white)
                .frame(width: 50, height: 50)
                .shadow (color: Color("AdaptativeTextSheetColor").opacity(shadowOpacity), radius: shadowRadius)
            Image(systemName: "plus.circle.fill")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .foregroundColor(.orange)
                .frame(width: 45, height: 45)
                .rotationEffect(Angle(degrees: showMenu ? 45 : 0))
        }
        .offset(y: -40)
    }
}

struct TabItem: View {
    let viewModel: TabBarViewModel
    @ObservedObject var router: ViewRouterTabBarView

    @Binding var showMenu: Bool

    var body: some View {
        Button {
            router.currentItem = viewModel
            withAnimation {
                showMenu = false
            }
        } label: {
            VStack {
                Image(systemName: viewModel.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 23, height: 23)
                    .frame(maxWidth: .infinity)
                Text(viewModel.tabLabel)
                    .font(.caption2)
            }
            .foregroundColor(router.currentItem == viewModel.self ? .accentColor : .gray)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

PopUpMenu:

import SwiftUI

struct PopUpMenu: View {

    @State private var showAddMeasures = false

    var body: some View {

        HStack(spacing: 24) {
            Spacer().fullScreenCover(isPresented: $showAddMeasures, content: AddMeasures.init)

            MenuItem(viewModel: .addMeasure)
                .onTapGesture {
                    showAddMeasures.toggle()
                }

            MenuItem(viewModel: .logWeight)

            Spacer()
        }
        .transition(.scale)
    }
}

struct MenuItem: View {

    let viewModel: MenuViewModel

    @State private var shadowOpacity: CGFloat = 0.8
    @State private var shadowRadius: CGFloat = 7

    @State private var shadowOpacityText: CGFloat = 0.3
    @State private var shadowRadiusText: CGFloat = 3

    var body: some View {
        VStack(alignment: .center, spacing: 12) {

            ZStack {
                Circle()
                    .foregroundColor(.orange)
                    .frame(width: 50, height: 50)
                Image(systemName: viewModel.imageName)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(12)
                    .foregroundColor(.white)
                    .frame(width: 50, height: 50)
            }
            .padding(.bottom, -23)
            Text(viewModel.tabLabel)
                .foregroundColor(.white)
                .font(.footnote)
                .frame(width:95, height:20)
                .background(.orange)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
        }
        .shadow (color: .black.opacity(shadowOpacityText), radius: shadowRadiusText,y: 3)
    }
}

struct PopUpMenu_Previews: PreviewProvider {
    static var previews: some View {
        PopUpMenu()
    }
}

ViewRouter:

import SwiftUI

class ViewRouterTabBarView: ObservableObject {
    @Published var currentItem: TabBarViewModel = .home

    var view: some View { return currentItem.view }
}

enum TabBarViewModel: Int, CaseIterable {
    case home
    case history
    case charts
    case profile

    var imageName: String {
        switch self {
        case .home: return "house.fill"
        case .history: return "calendar.badge.clock"
        case .charts: return "chart.xyaxis.line"
        case .profile: return "person.crop.square"
        }
    }

    var tabLabel: String {
        switch self {
        case .home: return "Home"
        case .history: return "History"
        case .charts: return "Charts"
        case .profile: return "Profile"
        }
    }

    var view: some View {
        switch self {
        case .home:
            return Text("Home")
        case .history:
            return Text("History")
        case .charts:
            return Text("Charts")
        case .profile:
            return Text("Profile")
        }
    }
}

enum MenuViewModel: Int, CaseIterable {
    case addMeasure
    case logWeight

    var imageName: String {
        switch self {
        case .addMeasure: return "note.text.badge.plus"
        case .logWeight: return "scalemass.fill"
        }
    }

    var tabLabel: String {
        switch self {
        case .addMeasure: return "Add Measure"
        case .logWeight: return "Log Weight"
        }
    }

}

AddMeasures:

import SwiftUI

struct AddMeasures: View {

    @Environment(\.dismiss) var dismiss

    var body: some View {
        Button {
            dismiss()
        } label: {
            Text("Dismiss")
        }

    }
}

struct AddMeasures_Previews: PreviewProvider {
    static var previews: some View {
        AddMeasures()
    }
}

Thank you very much for all the help you can provide :)

1      

You have to pass @State private var showMenu = false all the way down where you fire up dismiss()

ContentView

if showMenu {
    PopUpMenu(showMenu: $showMenu) // Start passing it down
        .padding(.bottom, 134)
        .zIndex(1)
        .onTapGesture {
            withAnimation {
                showMenu.toggle()
            }
        }
}

PopUpMenu

struct PopUpMenu: View {
    @Binding var showMenu: Bool // pass it to PopUpMenu

    @State private var showAddMeasures = false

    var body: some View {

        HStack(spacing: 24) {
            Spacer().fullScreenCover(isPresented: $showAddMeasures) {
                AddMeasures(showMenu: $showMenu) // modify accordingly how you call AddMeasures
            }

            MenuItem(viewModel: .addMeasure)
                .onTapGesture {
                    showAddMeasures.toggle()
                }

            MenuItem(viewModel: .logWeight)

            Spacer()
        }
        .transition(.scale)
    }
}

AddMeasures

struct AddMeasures: View {
    @Binding var showMenu: Bool // Pass it to AddMeasures
    @Environment(\.dismiss) var dismiss

    var body: some View {
        Button {
            showMenu.toggle() // toggle it to make buttons disappear
            dismiss()
        } label: {
            Text("Dismiss")
        }

    }
}

The logic is to toggle showMenu cuz it makes you buttons appear. So to make them disappear you have to revert showMenu back to false state.

1      

Thank you very much @ygeras!

I'm pretty sure I tried that, but somehow didn't work for me. Great explanation too.

1      

Hacking with Swift is sponsored by String Catalog.

SPONSORED Get accurate app localizations in minutes using AI. Choose your languages & receive translations for 40+ markets!

Localize My App

Sponsor Hacking with Swift and reach the world's largest Swift community!

One more question if I may: I thought I had figured out how to detect a tap done on none of the menu item, which makes the PopUp menu disappear, as I want, but the side effect is that I have to long press now to execute the actions, instead of a single tap.

In ContentView, I've added an onTapGesture, as follow:

var body: some View {
        ZStack(alignment: .bottom) {
            VStack {
                Spacer()

                router.view

                Spacer()
                HStack {
                    TabItem(viewModel: .home, router: router, showMenu: $showMenu)
                    TabItem(viewModel: .history, router: router, showMenu: $showMenu)

                    TabPlusIcon(showMenu: $showMenu)
                        .onTapGesture {
                            withAnimation {
                                showMenu.toggle()
                            }
                        }

                    TabItem(viewModel: .charts, router: router, showMenu: $showMenu)
                    TabItem(viewModel: .profile, router: router, showMenu: $showMenu)
                }
                .frame(height: UIScreen.main.bounds.height / 10)
                .frame(maxWidth: .infinity)
            }
            .onTapGesture(perform: { // <--- Here I've added it
                showMenu = false
            })
            if showMenu {
                PopUpMenu()
                    .padding(.bottom, 134)
                    .zIndex(1)
                    .onTapGesture {
                        withAnimation {
                            showMenu.toggle()
                        }
                    }
            }
        }
        .ignoresSafeArea()
    }

Again, it works for closing the PopUp menu, but renders the usability cumbersome.

1      

Sorry didn't get that part

but the side effect is that I have to long press now to execute the actions, instead of a single tap.

What do you have to long press? Cuz I can press without long press those showmenu buttons and it dismisses them.

When you added below code

.onTapGesture(perform: { // <--- Here I've added it
                showMenu = false
            })

only router.view that is the name for example History or Home etc. becomes tappable not the area around it and the HStack at the bottom without buttons. Buttons work with their own code. If you want white empty area to react to taps you can add

.contentShape(Rectangle())

before

.onTapGesture(perform: { // <--- Here I've added it
                showMenu = false
            })

BUT I don't think you app will use empty screens so you have to think your logic here. You already use to many gestures so you'd better to refactor it. Otherwise you'll soon confuse even yourself what is responsible for what.

1      

My apologies if I was not clear. What I meant to say is that the behavior of hiding the popup menu works when tapping anywhere in router.view however, if I want to interact with anything in the view, I have to long tap instead of single tap.

In my actual app, I have views displayed when tapping on an icon in the tab bar, and these views have lists and buttons and text boxes. And they don't react to single tap anymore.

I'm probably going at this the wrong way, and I'm all ears if there is a better way to do it.

Eventually, what I need is the following behavior:

When I tap on the + icon, the menu shows or hides based on if it's the first tap or second tap => that works fine When I tap on any of the menu icons, an action is triggered and the menu hides => that works now fine thanks to you When I tap anywhere else on the screen (as in not the + sign or a menu item), the menu hides => this is where I have the issue. I've put the logic on the icons of the tab bar, but I need the same on the rest of the screen, aka router.view

Hope this helps understand better :)

1      

Well without details of what you have on your tab views it is difficult to say what exactly interferes with you tap gesture. But definitely if you have buttons and other tappable items on the screen and on top put another .onTapGesture it will not work in a proper way. As a workaround try the following: On your router.view add .disabled(showMenu). Theoretically and hope practically it will make all items in that view disabled so buttons and all tappable stuff will become untappable when your showMenu is on. Then as you already have added .onTapGesture to VStack you just add contentShape before it like so:

  .contentShape(Rectangle())
  .onTapGesture { // <--- Here I've added it
      withAnimation {
          showMenu = false
      }
  }

It will make all area of that VStack kind of tappable. So it should change your state of showMenu to false when you tap in that area. And as you disabled all items above they will not react to their own taps but only the taps in contentShape will be recognized. And once you tap and showMenu disappears those items become active again and you can use them.

1      

Thanks for the effort, but it's still behaving the same, as in the PopUp hides wherever I tap, but it still interacts with the regular tap gesture and does not do anything with a simple tap. I'll have to figure this out another way. It's not critical, but in terms of user experience, you'd expect the PopUp menu to hide when you tap anywhere else, not just stays open like it does now.

1      

Hi @mezzomix23, Try adding this to your ContenView bellow your ZStack(alignment: .bottom):

if showMenu {
    Color.red
        .opacity(0.001)
        .onTapGesture {
            withAnimation {
                showMenu = false
            }
        }
}

1      

Hi @Hectorcrdna. Did exaclty work, was only using a portion of the screen doing so. But experimenting with the idea, I ended up adding this, for now (as I could always change my mind :-D):

var body: some View {
        ZStack(alignment: .bottom) {
            VStack {
                Spacer()

                router.view
                if showMenu {
                    Color("AdaptativeTextSheetColor")
                        .frame(height: UIScreen.main.bounds.height - 117)

                        .blur(radius: 250, opaque: false)
                        .onTapGesture {
                            withAnimation {
                                showMenu = false
                            }
                        }
                }
                ......

I know having hardcoded 117 is not ideal, but for now and my tests it works, but will need to figure out the right formula to make sure the tab bar does not move up and down when the color happens.

Thank you for the idea :-)

1      

Hacking with Swift is sponsored by String Catalog.

SPONSORED Get accurate app localizations in minutes using AI. Choose your languages & receive translations for 40+ markets!

Localize My App

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.