Alerts on parent and child views in SwiftUI 2.0 / Xcode 12

Viewed 895

Can someone please explain to me why this doesn't work in SwiftUI 2.0 / iOS 14 / Xcode 12 and a valid workaround? AlertOne works fine but not AlertTwo. I'm trying to have alerts on both parent and child (nested) views. You can plug this right into Xcode to test if needed. Thank you.

import SwiftUI

struct ContentView: View {
    @State private var alertOne = false

    var body: some View {
        VStack {
            Button("Alert One") {
                self.alertOne.toggle()
            }
            TestView().padding()
        }
        .alert(isPresented: $alertOne) {
            Alert(title: Text("Alert One"), dismissButton: .default(Text("Got it!")))
        }
    }
}

struct TestView: View {
    @State private var alertTwo = false
    
    var body: some View {
        Button("Alert Two") {
            self.alertTwo.toggle()
        }
        .alert(isPresented: $alertTwo) {
            Alert(title: Text("Alert Two"), dismissButton: .default(Text("Got it!")))
        }
    }
}

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

Here is a possible way to structure the code:

struct ContentView: View {
    @State private var showAlert = false
    @State private var alert: Alert? = nil

    var body: some View {
        VStack {
            Button("Alert One") {
                alert = Alert(title: Text("Alert One"), dismissButton: .default(Text("Got it!")))
                self.showAlert.toggle()
            }
            TestView(showAlert: $showAlert, alert: $alert).padding()
        }
        .alert(isPresented: $showAlert) {
            alert!
        }
    }
}

struct TestView: View {
    @Binding var showAlert: Bool
    @Binding var alert: Alert?
    
    var body: some View {
        Button("Alert Two") {
            alert = Alert(title: Text("Alert Two"), dismissButton: .default(Text("Got it!")))
            self.showAlert.toggle()
        }
    }
}

Here is an alternative that uses a new type IdentifiableAlert. This allows you to get rid of the separate showAlert and to just show an alert anytime an IdentfiableAlert is assigned to alert:

struct IdentifiableAlert: Identifiable {
    let alert: Alert
    let id = UUID()
}

struct ContentView: View {
    @State private var alert: IdentifiableAlert? = nil

    var body: some View {
        VStack {
            Button("Alert One") {
                alert = IdentifiableAlert(alert: Alert(title: Text("Alert One"), dismissButton: .default(Text("Got it!"))))
            }
            TestView(alert: $alert).padding()
        }
        .alert(item: $alert) { alert in
            alert.alert
        }
    }
}

struct TestView: View {
    @Binding var alert: IdentifiableAlert?
    
    var body: some View {
        Button("Alert Two") {
            alert = IdentifiableAlert(alert: Alert(title: Text("Alert Two"), dismissButton: .default(Text("Got it!"))))
        }
    }
}

Here's a handy extension that turns an Alert into an IdentifiableAlert():

extension Alert {
    func identifiable() -> IdentifiableAlert { IdentifiableAlert(alert: self) }
}

so this:

alert = IdentifiableAlert(alert: Alert(title: Text("Alert Two"), dismissButton: .default(Text("Got it!"))))

can be replace with this:

alert = Alert(title: Text("Alert Two"), dismissButton: .default(Text("Got it!"))).identifiable()

I think it is a bug, but actually SwiftUI behaves this way - it does not not allow several sheets or alerts in one view hierarchy (because I assume they use view preference inside, so once it set in one cycle it is not updated).

The possible solutions are either to use one .alert for view hierarchy and configure it from different parts, or to separate view hierarchies artificially with helper hosting view.

Tested with Xcode 12 / iOS 14

The only change in your code // ...

    // you can wrap any part at any level to make it independent 
    // on existing view hierarchy !!
    HelperView { TestView().padding() }.fixedSize()

    // ... 

and helper view

struct HelperView<Content: View>: UIViewRepresentable {
    let content: () -> Content

    func makeUIView(context: Context) -> UIView {
        let controller = UIHostingController(rootView: content())
        return controller.view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {}
}
Related