iosswiftswiftuimvvmalert

SwiftUI Alert can't be triggered from subview


I find myself with several Alert boxes in my app for various purposes. In pursuit of the goal of DRY (don't repeat yourself), I want to centralize this code. My naieve approach is to write a ViewModel that controls Alert state, both content and whether or not it's showing. That code is simple

@MainActor
class AlertViewModel: ObservableObject {
    @Published var showAlert = false
    @Published var title: String = "Default Alert title"
    @Published var message: String = "Default Alert Message"
}

Then you can use it like this

struct MainView: View {
    @StateObject var vm = AlertViewModel()
    var body: some View {
        Button("Show alert on main view") {
            vm.showAlert = true
        }
        .alert(isPresented: $vm.showAlert) {
            Alert(title: Text(vm.title), message: Text(vm.message))
        }
    }
}

And it works beautifully. However, using it from a subview doesn't work. Here's the above code combined with a subview

struct SubView: View {
    @StateObject var vm = AlertViewModel()
    var body: some View {
        Button("Show alert on sub view") {
            vm.showAlert = true
        }
    }
}
struct MainView: View {
    @StateObject var vm = AlertViewModel()
    var body: some View {
        SubView()
        Button("Show alert on main view") {
            vm.showAlert = true
        }
        .alert(isPresented: $vm.showAlert) {
            Alert(title: Text(vm.title), message: Text(vm.message))
        }
    }
}

The subview button doesn't cause the alert to show.

Why?

Is there a workaround?

Am I needlessly chasing code minimalism?

What's the correct approach here?


Solution

  • When a MainView is first created @StateObject will instantiate the AlertViewModel. In SubView you have used @StateObject, this will create another instance of AlertViewModel. There are two different AlertViewModel instances, that is why the alert did not show when button is clicked in SubView.

    To use the same instance from MainView, you should pass the AlertViewModel

    SubView(vm: vm)
    

    In SubView use ObservedObject, if you want the SubView to update if any published property of AlertViewModel changes

    @ObservedObject var vm: AlertViewModel
    

    or else you can use var vm: AlertViewModel

    try the below code body in SubView to check the difference, with and without @ObservedObject

    var body: some View {
        Button("Show alert on sub view one") {
            vm.showAlert = true
        }
        Rectangle()
            .foregroundStyle(vm.showAlert ? Color.red : Color.green)
    }
    

    If you want to pass AlertViewModel to random subView without passing AlertViewModel to all subView like a chain, you can use environment Object

    struct MainView: View {
        @StateObject var vm = AlertViewModel()
        var body: some View {
            VStack {
                SubViewOne()
                Button("Show alert on main view") {
                    vm.showAlert = true
                }
                .alert(isPresented: $vm.showAlert) {
                    Alert(title: Text(vm.title), message: Text(vm.message))
                }
            }
            .environmentObject(vm)
        }
    }
    
    struct SubViewOne: View {
        var body: some View {
            SubViewTwo()
        }
    }
    
    struct SubViewTwo: View {
        @EnvironmentObject var vm: AlertViewModel
        var body: some View {
            Button("Show alert on sub view two") {
                vm.showAlert = true
            }
        }
    }