Combine two boolean bindings/state variables to gate an alert?

I have a "yes/no" confirmation alert I want to show to a user. If they say yes, we proceed with an action, otherwise we don't. But I'd like to have an "expert" mode where the user isn't prompted and we just proceed with the action.

The question is, how do I avoid having to write the code that runs the action (even if it is as single function call) at two different code sites? I.e.

HStack {   .... }.alert(isPresented: $showConfirm) {
       Alert(title: Text("Do Something"),
               primaryButton: .default(Text("Yes"), { runMyCode() }),
               secondaryButton: .cancel())
       }

will suffice to show the alert. But at the point where I might set showConfirm, I must do this:

if (self.expertMode) {
    runMyCode()
}
else {
    self.showConfirm = true
}

So now I've listed runMyCode() twice, which I really want to avoid.
What I'd really like to be able to do is write something like this:

}.askYesNo(isPresented: $self.showConfirm,
           autoConfirmWhen: $self.expertMode, action: { runMyCode() })

which would take care of assembling the alert for me, but only if expertMode was false. Otherwise, it wouldn't bother, and it just run the specified action. If it did assemble the alert, the action parameter would be run if the user presses "Yes".

As an alternate question if nobody can figure that out, how could i do something like

isPresented: $self.showConfirm && !$self.expertMode

I.e. given two boolean variables, I want to pass in a binding which is the boolean and of their values. Can't figure out how to make a new one.

Yes, I can do all this by writing the logic out for all of it explicitly, to make it happen. The point is to find a simple pattern that avoids code replication and is as easy as a oneline modifier (askYesNo()) that encapsulates all the info (text, action, condition for running or not showing) at one call point.

You could try creating your own binding like so:

let isPresented = Binding(
    get: { self.showConfirm && !self.expertMode },
    set: { newValue in /* implement */ }
)

The tricky part is figuring out how to write a "set" back to showConfirm and expertMode, at least in the general case (detached from this concrete scenario). I believe a simple self.showConfirm = newValue might be what you're looking for here though.

1 Like

Ah. Perhaps what I want is

let isPresented = Binding(
    get: { self.showConfirm && !self.expertMode },
    set: { newValue in 
             self.showConfirm = newValue
             if self.showConfirm && self.expertMode {
                    runMyCode()
            }
         }
     )

So then I just have to figure out how to inject "runMyCode()" into both the binding and the produced Alert from a single code site. Not sure if this will lead to a solution, but it's something new to try.

I don't think you should call runMyCode() from inside the binding's setter; instead, only update the state self.showConfirm and let SwiftUI figure out the rest.

You may well be right that running my code from inside the set method is a bad idea, but if I knew how to let SwiftUI "figure out the rest" I wouldn't be asking. There need to be two spots that ultimately invoke runMyCode(): upon confirmation of the alert dialog (if it is shown) and if it is NOT shown, then immediately upon isPresented turning true when exportMode is also true.

Any ideas on how to make SwiftUI "figure that out"?

Could you please share a small running example of what you are currently doing?

So I'll give an answer to my own question. I was able to do what I wanted after learning about @ViewBuilder, so that I could conditionally build a view that presented an Alert() and one which did not.

The implementation is slightly more complex than I thought this would be, but actually using it turns out to be as nice as I could want. Let me show you how you use it:

HStack { ... }.askYesNo(isPresented: $confirmReset, title: "Reset Data?",
               message: "This may void the warranty.",
               yesText: "Clear",
               autoConfirm: $autoClear, action: { self.resetSomeData() })

So this is like your basic alert. When confirmReset kicks over to true, the alert dialog comes up asking if you really really want to clear that data. BUT: if autoClear is also true, it just immediately calls self.resetSomeData() and doesn't ask you.

Here's the actual implementation:

 public struct AskYesNoAlertView<Content> : View where Content : View {
    @Binding var isPresented: Bool
    @Binding var autoConfirm: Bool
    let action: () -> ()
    let title: String
    let message: String?
    let yesText: String
    var content: Content

    public init(isPresented: Binding<Bool>, autoConfirm: Binding<Bool>,
                action: @escaping () -> (), title: String,
                message: String?, yesText: String,
                @ViewBuilder content: () -> Content) {
        _isPresented = isPresented
        _autoConfirm = autoConfirm
        self.action = action
        self.title = title
        self.message = message
        self.yesText = yesText
        self.content = content()
    }

    func runIfAutoconfirm() -> Bool {
        if autoConfirm {
            if isPresented {
                action()
                self.isPresented = false
            }
            return true
        }
        return false
    }

    public var body: some View {
        ZStack {
            if runIfAutoconfirm() {
                content
            }
            else {
                content.alert(isPresented: self._isPresented) {
                    Alert(title: Text(title),
                          message: Text(message ?? ""),
                          primaryButton: .default(Text(yesText)) { self.action() },
                          secondaryButton: .cancel())
                }
            }
        }
    }
}

public extension View {
    func askYesNo(isPresented: Binding<Bool>, title: String, message: String?,
                  yesText: String, noText: String = "Cancel", autoConfirm: Binding<Bool>,
                  action: @escaping () -> ()) -> AskYesNoAlertView<Self> {
       AskYesNoAlertView(isPresented: isPresented,
                          autoConfirm: autoConfirm,
                          action: action,  title: title,
                          message: message,  yesText: yesText) {
                            self
        }
    }
}

When running this I get the runtime warning [SwiftUI] Modifying state during view update, this will cause undefined behavior. and subsequent attempts to trigger the action no longer work. I have isPresented bound to a Toggle and use it to show the alert. How are you using this?

On another note, I believe autoConfirm should be a plain Bool, not a Binding<Bool>.

I am able to do subsequent deletions, though I’m also receiving that error.
Wrapping it in a DispatchQueue.main.async { } silences the warning, and the behavior continues to be correct.

I filly admit this is the part of the code I am admittedly most nervous about: it’s a requirement that the action closure run exactly once for each time that isPresented switches from false to true, when autoConfirm is true.

I would be thrilled if someone would offer a great suggestion of how to safely bind calling a closure with the transition of a Binding variable. (Not counting manufacturing of your own new binding variable; needs to be one you’re just given and need to monitor.)

You may very well be correct that autoConfirm doesn’t have to be a Binding; I also bound it to a toggle to test, but I haven’t been able to try just making it be a plain bool instead of a Binding.

Yes, autoConfirm should just be a plain Bool.

Also, I now think we should do

DispatchQueue.main.async {
    self.isPresented = false
    self.action()
}

because given the strong requirement that we only call action once per transition of isPresented, we should tie calling the action and mutating that state as closely together as possible.

My test case has the resetConfirm binding be a state variable that is being mutated when someone invokes a context menu on a list item and says, "Clear this item." That's a more typical use case.

If you use a toggle, the expected behavior is that as soon as you activate the toggle, it has to deactivate. in contrast, setting the state from say a context menu doesn't involve "fighting" over what value the state variable has --- it's a one and done sort of deal. (Yeah, that was hand-wavy).