skip to Main Content

When a SwiftUI view has a complex logic, i like to employ an enum to represent it’s state (often in a viewmodel/service).

struct FooView {

    enum ProgressState {
        case idle
        case loading
        case completed(Model)
        case error

        var stateName: String { ... }
    }

    @State var state: ProgressState = .idle
    ...

PROBLEM:

What i’d like tho, is a way or methodology to connect bindables to a specific enum case. In this example, to show an alert (or a sheet), since something like <<$state.error>> is obviously not possible.

    ...
    var body: some View {
        Text(state.stateName)

        // show alert on error
        .alert("oops there was an error", 
               isPresented: <<$state.error>>) 
        { //1.
            ...
        }
        
    }
}

CURRENT ALTERNATIVE

The easiest solution is to use additional variables, but i really do not like to keep them synchronised manually.

BETTER SOLUTIONS?

I was exploring using combine to update the additional variable upon state var changes, the problem is always that the bindable needs to change it’s state as well, but in this case the alert, does not know to which state to bring it back to.

So maybe a protocol for the enum and an extension to alert and sheet to handle these instead of Bindable ? Has anybody tried a similar approach or know a better one?

Thank you

3

Answers


  1. Method 1 – Without helpers variavles

    You can bind it directly to the error state like:

    .alert("oops there was an error", isPresented: .constant(state == .error)) { Button("idle") { state = .idle } }
    

    ⚠️ note that the default OK button will not change the state in this method


    Method 2 – With a Binding<Bool> variable in between

    Another way is that you can wire-up the state to the binding:

    var body: some View {
        let shouldShowError = Binding<Bool> {
            state == .error
        } set: { _ in
            guard state == .error else { return }
            state = .idle
        }
    
        Button(state.stateName) { state = .error }
            .alert("oops there was an error", isPresented: shouldShowError)
        { }
    }
    

    💡 Note that there is no need to manually tell the view what to do like what we do in the UIKit, but you must declare what the state would be in every situation.

    For this case, it is obvious that you need to see the alert on the error state, but it’s not clear the other way. So you should filter it down to just 2 states (a bool) anyway.

    Login or Signup to reply.
  2. You just need to use a computed binding like :

    // show alert on error
        .alert("alert", isPresented: Binding<Bool>(get: {state == .error},
                                                   set: {_ in})) {
                Button("idle", action: {state = .idle})
                Button("loading", action: {state = .loading})
                ...
            }
    
    Login or Signup to reply.
  3. SwiftUI provides a solution to present LocalizedError

    func alert<E, A>(
        isPresented: Binding<Bool>,
        error: E?,
        @ViewBuilder actions: () -> A
    ) -> some View where E : LocalizedError, A : View
    

    You can create a simple enum that can present any standard Error or custom errors.

    enum LocalError: LocalizedError {
        //Use for any built in error
        case error(Error)
        //Use for something custom
        case invalidId
        
        var errorDescription: String? {
            switch self {
            case .error(let error):
                return error.localizedDescription
            case .invalidId:
                return "(self)"
            }
        }
        var recoverySuggestion: String? {
            switch self {
            case .error(let error):
                let nsError = error as NSError
                return nsError.localizedRecoverySuggestion
            default:
                return nil
            }
        }
    }
    

    Then you can modify your progress enum.

    enum ProgressState {
        case idle
        case loading
        case completed(Model)
        case error(Error)
    

    You can then trigger an alert (or sheet) when that state is triggered

    switch state {
    case .error(let error):
        Text(state.stateName)
            .task {
                alert = (true, .error(error))
            }
    default :
        Text(state.stateName)
    }
    

    Here is the full code.

    import SwiftUI
    
    struct FooView: View {
        
        enum ProgressState {
            case idle
            case loading
            case completed(Model)
            case error(LocalError)
            
            
            var stateName: String {
                switch self {
                case .completed(_):
                    return "Complete"
                case .error(_):
                    return "Something went wrong"
                default:
                    return "(self)"
                }
            }
        }
        
        @State private var state: ProgressState = .idle
        @State private var alert: (isPresented: Bool, error: LocalError?) = (false, nil)
        var body: some View {
            Group {
        switch state {
        case .error(let error):
            Text(state.stateName)
                .task {
                    alert = (true, error)
                }
        default :
            Text(state.stateName)
        }
            }.alert(isPresented: $alert.isPresented, error: alert.error) {
                Button("Ok") {
                    alert = (false, nil)
                }
            }
            .task {
                try? await Task.sleep(for: .seconds(1))
                state = .error(.invalidId)
            }
        }
        
        struct Model {
            
        }
        
        enum LocalError: LocalizedError {
            //Use for any built in error
            case error(Error)
            //Use for something custom
            case invalidId
            
            var errorDescription: String? {
                switch self {
                case .error(let error):
                    return error.localizedDescription
                case .invalidId:
                    return "(self)"
                }
            }
            var recoverySuggestion: String? {
                switch self {
                case .error(let error):
                    let nsError = error as NSError
                    return nsError.localizedRecoverySuggestion
                default:
                    return nil
                }
            }
        }
    }
    
    #Preview {
        FooView()
    }
    

    This provides an independent alert variable so the presentation of the .alert does not conflict with the View behind it or make any unsafe assumptions.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search