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
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!
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: