2

When having multiple buttons with popovers in an HStack, I get weird behavior. Whenever you tap one button, the popup shows up correctly. But, when you click on the second item, the first popover quickly closes then reopens. Expected behavior is that it closes the first popover and opens the second. Xcode 12.5.1, iOS 14.5

enter image description here

Here's my code:

struct ContentView: View {

var items = ["item1", "item2", "item3"]

var body: some View {
    HStack {
        MyGreatItemView(item: items[0])
        MyGreatItemView(item: items[1])
        MyGreatItemView(item: items[2])
    }
    .padding(300)
}

struct MyGreatItemView: View {
    @State var isPresented = false
    var item: String
    
    var body: some View {
        
        Button(action: { isPresented.toggle() }) {
            Text(item)
        }
        .popover(isPresented: $isPresented) {
            PopoverView(item: item)
        }
        
    }
}

struct PopoverView: View {
    @State var item: String
    
    var body: some View {
        print("new PopoverView")
        return Text("View for \(item)")
    }
}


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

}

Thanks for any help!

MostPalone
  • 77
  • 7

1 Answers1

2

Normally you'd use popover(item:content:), but you'll get an error... even the example in the documentation crashes.

*** Terminating app due to uncaught exception 'NSGenericException', reason: 'UIPopoverPresentationController (<UIPopoverPresentationController: 0x14a109890>) should have a non-nil sourceView or barButtonItem set before the presentation occurs.'

What I came up with instead is to use a singular @State presentingItem: Item? in ContentView. This ensures that all the popovers are tied to the same State, so you have full control over which ones are presented and which ones aren't.

But, .popover(isPresented:content:)'s isPresented argument expects a Bool. If this is true it presents, if not, it will dismiss. To convert presentingItem into a Bool, just use a custom Binding.

Binding(
    get: { presentingItem == item }, /// present popover when `presentingItem` is equal to this view's `item`
    set: { _ in presentingItem = nil } /// remove the current `presentingItem` which will dismiss the popover
)

Then, set presentingItem inside each button's action. This is the part where things get slightly hacky - I've added a 0.5 second delay to ensure the current displaying popover is dismissed first. Otherwise, it won't present.

if presentingItem == nil { /// no popover currently presented
    presentingItem = item /// dismiss that immediately, then present this popover
} else { /// another popover is currently presented...
    presentingItem = nil /// dismiss it first
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        presentingItem = item /// present this popover after a delay
    }
}

Full code:

/// make equatable, for the `popover` presentation logic
struct Item: Equatable {
    let id = UUID()
    var name: String
}

struct ContentView: View {
    
    @State var presentingItem: Item? /// the current presenting popover
    let items = [
        Item(name: "item1"),
        Item(name: "item2"),
        Item(name: "item3")
    ]

    var body: some View {
        HStack {
            MyGreatItemView(presentingItem: $presentingItem, item: items[0])
            MyGreatItemView(presentingItem: $presentingItem, item: items[1])
            MyGreatItemView(presentingItem: $presentingItem, item: items[2])
        }
        .padding(300)
    }
}

struct MyGreatItemView: View {
    @Binding var presentingItem: Item?
    let item: Item /// this view's item
    
    var body: some View {
        Button(action: {
            if presentingItem == nil { /// no popover currently presented
                presentingItem = item /// dismiss that immediately, then present this popover
            } else { /// another popover is currently presented...
                presentingItem = nil /// dismiss it first
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                    if presentingItem == nil { /// extra check to ensure no popover currently presented
                        presentingItem = item /// present this popover after a delay
                    }
                }
            }
        }) {
            Text(item.name)
        }
        
        /// `get`: present popover when `presentingItem` is equal to this view's `item`
        /// `set`: remove the current `presentingItem` which will dismiss the popover
        .popover(isPresented: Binding(get: { presentingItem == item }, set: { _ in presentingItem = nil }) ) {
            PopoverView(item: item)
        }
    }
}

struct PopoverView: View {
    let item: Item /// no need for @State here
    var body: some View {
        print("new PopoverView")
        return Text("View for \(item.name)")
    }
}

Result:

Presenting popovers consecutively works

aheze
  • 24,434
  • 8
  • 68
  • 125
  • 1
    This works a lot better than my previous code. Thanks. The only problem I run into is that if you tap through them too quickly it can cause a crash. It's an edge case, but something that a user might do for some reason. – MostPalone Sep 10 '21 at 17:16
  • @MostPalone is it the same "should have a non-nil sourceView or barButtonItem set" error? – aheze Sep 10 '21 at 18:35
  • @MostPalone edited my answer with an extra `if presentingItem == nil {` check. This makes sure that there is currently no popover before presenting one. Let me know if it works. – aheze Sep 14 '21 at 03:23