swiftswiftuiswiftui-foreach

Popovers not displayed inside ForEach over Enum types


Popovers (and sheets) will not be displayed if they are triggered from inside of a ForEach(<any enum type>) whereas they work fine if there is a ForEach(<struct type>).

First, I have made sure to avoid accidentally triggering more than one modal at a time by using a custom view within ForEach. This avoids the case of many views sharing a single showPopover property. The only other similar issues and solutions I've seen online relate to a bug when presenting a sheet or popover within NavigationView.

Following quick and dirty example app which demonstrates the issue follows. A Fruit is built with a struct while a Vegetable is built with an enum. These are silly cases but architecturally there are many situations where storing data in enums is helpful.

You can click each of the buttons and see that the button in the parent view DemoIssue as well as each FruitView work as expected, showing a popover on tap. However the VegetableView when tapped does not do anything.

Notice also the debug output when pressing the buttons. Tapping a Fruit will report false->true, true->false etc. whereas a Vegetable will always report false->true.

import Foundation
import SwiftUI

struct BoxOfThings {
    var fruits: [Fruit] = []
    var vegetables: [Vegetable] = []
}

struct Fruit: Identifiable {
    var id = UUID()
    var description: String
}

struct FruitView: View {
    @Binding var thing: Fruit
    
    @State private var showPopover: Bool = false
    
    var body: some View {
        Button(thing.description) {
            showPopover = true
        }
        .popover(isPresented: $showPopover) {
            Text("Peek-a-boo!")
        }
        .onChange(of: showPopover) { oldVal, newVal in
            print("Fruit button changed showPopover from \(oldVal) to \(newVal)")
        }
    }
}

enum Vegetable: String, Identifiable {
    case tomato, cucumber, lettuce
    
    var id: UUID { UUID() }
    var description: String { self.rawValue }
}

struct VegetableView: View {
    @Binding var thing: Vegetable
    
    @State private var showPopover: Bool = false
    
    var body: some View {
        Button(thing.description) {
            showPopover = true
        }
        .popover(isPresented: $showPopover) {
            Text("Peek-a-boo!")
        }
        .onChange(of: showPopover) { oldVal, newVal in
            print("Vegetable button changed showPopover from \(oldVal) to \(newVal)")
        }
    }
}

struct DemoIssue: View {
    @State var boxOfThings: BoxOfThings = BoxOfThings(
        fruits: [
            Fruit(description: "Lemons"), Fruit(description: "Oranges"), Fruit(description: "Peaches")
        ],
        vegetables: [
            Vegetable.tomato, Vegetable.cucumber, Vegetable.lettuce
        ]
    )
    
    @State private var showPopover: Bool = false
    
    var body: some View {
        VStack {
            Button("Parent Popover") {
                showPopover = true
            }
            .popover(isPresented: $showPopover) {
                Text("Peek-a-boo!")
            }
            
            Text("Fruits")
            ForEach($boxOfThings.fruits) { $fruit in
                FruitView(thing: $fruit)
            }
            
            Text("Vegetables")
            ForEach($boxOfThings.vegetables) { $vegetable in
                VegetableView(thing: $vegetable)
            }
        }
    }
}

Solution

  • Paulw11's and Joakim Danielson's comments are correct and resolve the issue in the example provided. Popovers and sheets will then display properly after this change:

                Text("Vegetables")
                ForEach($boxOfThings.vegetables, id: \.self) { $vegetable in
                    VegetableView(thing: $vegetable)
                }
    

    In my actual app, I had to define id as a stored property on items. Swift could not automatically conform the enum to Hashable so \.self could not be used but it doesn't matter because id/Identifiable does the job. A contrived but more realistic example:

    enum FoodStuff: Identifiable {
        case fruit(FruitItem)
        case vegetable(VegetableItem)
        case meal(Meal)
        
        var id: UUID {
            switch self {
            case .fruit(let item): return item.id
            case .vegetable(let item): return item.id
            case .meal(let item): return item.id
            }
        }
    }
    
    protocol MealIngredient {
        // ...etc
    }
    
    struct FruitItem: MealIngredient {
        let id = UUID()
        // ...etc
    }
    
    struct VegetableItem: MealIngredient {
        let id = UUID()
        // ...etc
    }
    
    struct Meal {
        let id = UUID()
        let ingredients: [MealIngredient]
        // ...etc
    }
    

    In SwiftUI, one can then do ForEach(foodItems) { foodItem in ... } and popovers/sheets work.

    If there is a better way to access the id of associated items (instead of the switch case) from the enum that could be helpful.