Search code examples
swiftuipopoverswiftui-viewswiftui-menu

Dismissable popover in SwiftUI


I'm trying to make a list where each row can be clicked to show a popover, and from the popover, there is another button to open whatever url in default web browser.

Everything seems to work as is, except for the fact that on clicking open a new web browser or clicking outside the popover, I want to popover to disappear. On clicking the web browser, the top app menu disappears, but on clicking the top app menu again, it shows up again with the popover still open.

I was wondering if there's a way to unset the selector variable in my app on outside click.

This is my code:

struct AppMenu: View {
    @State private var selector: String = ""
    @State private var isPopoverPresented = false

    private func togglePopover(_ selector: String) {
        selector = ""
        selector = selector
    }
    
    var body: some View {
            VStack {
                List {
                    ForEach(itemList, id: \.self) { item in
                        Button(action: {
                            togglePopover(item.Id)
                        }) {
                            Label(item.Content, systemImage: "arrow.right.circle.fill")
                            // Customize the color if needed
                        }
                        .popover(isPresented: Binding<Bool>(
                            get: { selector == item.Id },
                            set: { _ in }
                        )) {
                            VStack {
                                Button("Open in sharari") {
                                    openURLInSafari(item.Content)
                                    isPopoverPresented = false
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

And this component is shown in the main app scene:

MenuBarExtra("MenuStuff", systemImage: "book.fill") { AppMenu()}.menuBarExtraStyle(.window);

I tried making an overlay view that's full screen window sized, and having that fire the unset, but it seem to not be working.


Solution

  • Putting each item row in a subview will make the popover presentation much easier. Each view has its own state variable, you don't have to handle the id.

    Here is a possible approach:

    struct AppMenu: View {
        var itemList: [Item] = [Item(URL(string: "https://google.com")!)]
        
        var body: some View {
            VStack {
                List {
                    ForEach(itemList, id: \.self) { item in
                        ItemView(item: item)
                    }
                }
            }
        }
    }
    
    struct ItemView: View {
        @ObservedObject var item: Item
        @Environment(\.openURL) var openUrl
        
        @State private var isLinkPresented = false
        
        var body: some View {
            Button(action: {
                isLinkPresented = true
            }) {
                Label(item.contentUrl.absoluteString.replacingOccurrences(of: "https://", with: ""), systemImage: "arrow.right.circle.fill")
            }
            .popover(isPresented: $isLinkPresented) {
                VStack {
                    // Link("\(item.contentUrl.absoluteString)", destination: item.contentUrl)
                    Button("Open in sharari") {
                        // openURLInSafari(item.Content) // rename to "item.contentUrl"
                        openUrl(item.contentUrl)
                        // isLinkPresented = false // this is not neccessary
                    }
                }
                .padding()
            }
        }
    }
    

    Setting the isPresented attribute of popover to false is not neccessary in SwiftUI. It will be automatically set when focus changes.

    I made up your Item object, as it was not implemented in your example:

    class Item: ObservableObject, Hashable {
        let id = UUID()
        let contentUrl: URL
        
        init(_ contentUrl: URL) {
            self.contentUrl = contentUrl
        }
        
        static func ==(lhs: Item, rhs: Item) -> Bool {
            return lhs.id == rhs.id
        }
        
        func hash(into hasher: inout Hasher) {
            hasher.combine(id)
        }
    }
    

    Note that you named a property Content, but properties should be llamaCased and meaningfully named, so I called it contentUrl, which makes it clearer what's going on.