swiftuicomputed-properties

Create a computed @State variable in SwiftUI


Imagine I'm designing a SwiftUI screen that asks for the user to enter a Username. The screen will do some checking to ensure the username is valid. If the username is invalid, it'll show an error message. If the user taps "Dismiss", it'll hide the error message.

In the end I may end up with something like this:

enter image description here

enum UsernameLookupResult: Equatable {
    case success
    case error(message: String, dismissed: Bool)

    var isSuccess: Bool { return self == .success }
    var isVisibleError: Bool {
        if case .error(message: _, dismissed: false) = self {
            return true
        } else {
            return false
        }
    }
    var message: String {
        switch self {
        case .success:
            return "That username is available."
        case .error(message: let message, dismissed: _):
            return message
        }
    }
}

enum NetworkManager {
    static func checkAvailability(username: String) -> UsernameLookupResult {
        if username.count < 5 {
            return .error(message: "Username must be at least 5 characters long.", dismissed: false)
        }

        if username.contains(" ") {
            return .error(message: "Username must not contain a space.", dismissed: false)
        }

        return .success
    }
}

class Model: ObservableObject {
    @Published var username = "" {
        didSet {
            usernameResult = NetworkManager.checkAvailability(username: username)
        }
    }
    @Published var usernameResult: UsernameLookupResult = .error(message: "Enter a username.", dismissed: false)

    func dismissUsernameResultError() {
        switch usernameResult {
        case .success:
            break
        case .error(message: let message, dismissed: _):
            usernameResult = .error(message: message, dismissed: true)
        }
    }
}

struct ContentView: View {
    @ObservedObject var model: Model

    var body: some View {
        VStack {
            Form {
                TextField("Username", text: $model.username)
                Button("Submit", action: {}).disabled(!model.usernameResult.isSuccess)
            }
            Spacer()
            if model.usernameResult.isSuccess || model.usernameResult.isVisibleError {
                HStack(alignment: .top) {
                    Image(systemName: model.usernameResult.isSuccess ? "checkmark.circle" : "xmark.circle")
                        .foregroundColor(model.usernameResult.isSuccess ? Color.green : Color.red)
                        .padding(.top, 5)
                    Text(model.usernameResult.message)
                    Spacer()
                    if model.usernameResult.isSuccess {
                        EmptyView()
                    } else {
                        Button("Dismiss", action: { self.model.dismissUsernameResultError() })
                    }
                }.padding()
            } else {
                EmptyView()
            }
        }
    }
}

As long as my "dismiss" action is a Button, it's easy to achieve the dismiss behavior:

Button("Dismiss", action: { self.model.dismissUsernameResultError() })

This will easily show error messages and dismiss them correctly.

Now imagine I want to use a different component instead of Button to call the dismiss method. Furthermore, imagine the component I use only takes in a Binding (e.g. a Toggle). (Note: I realize this is not an ideal component to use, but this is for illustrative purposes in this simplified demo app.) I may attempt to create a computed property to abstract this behavior and end up with:

@State private var bindableIsVisibleError: Bool {
    get { return self.model.usernameResult.isVisibleError }
    set { if !newValue { self.model.dismissUsernameResultError() } }
}

// ...


// replace Dismiss Button with:
Toggle(isOn: $bindableIsVisibleError, label: { EmptyView() })

... however, this is not valid syntax and produces the following error on the @State line:

Property wrapper cannot be applied to a computed property

How can I create a bindable computed property? I.e. a Binding with a custom getter and setter.


Although not ideal as it would (A) only provide a setter, and (B) adds state duplication (which goes against SwiftUI's single source of truth principal), I thought I would be able to solve this with a normal state variable:

@State private var bindableIsVisibleError: Bool = true {
    didSet { self.model.dismissUsernameResultError() }
}

This does not work, though as didSet is never called.


Solution

  • Here is an approach I prefer with computed property & binding "on-the-fly"

    private var bindableIsVisibleError: Binding<Bool> { Binding (
        get: { self.model.usernameResult.isVisibleError },
        set: { if !$0 { self.model.dismissUsernameResultError() } }
        )
    }
    

    and usage (as specified)

    Toggle(isOn: bindableIsVisibleError, label: { EmptyView() })