Search code examples
iosswiftswiftuienumscombine

SwiftUI binding to specific enum case or cases


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


Solution

  • 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.