Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 327
Senseful Avatar asked Dec 15 '19 03:12

Senseful


People also ask

How do I create a computed property in Swift?

Swift Computed Property For example, class Calculator { // define stored property var num1: Int = 0 ... } Here, num1 is a stored property, which stores some value for an instance of Calculator . Here, sum is a computed property that doesn't store a value, rather it computes the addition of two values.

How do I make a property computed?

Defining a computed property To start, you have to write a variable and explicitly declare the type of the property to help the compiler know what kind of value will be assigned to it. Do not assign a default value. Instead open a bracket after the type declaration and start working on the getter.

What are stored and computed properties in Swift?

Stored properties store constant and variable values as part of an instance, whereas computed properties calculate (rather than store) a value. Computed properties are provided by classes, structures, and enumerations. Stored properties are provided only by classes and structures.

What is a computed property?

A computed property is a property that calculates and returns a value, rather than just store it.


Video Answer


2 Answers

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() })
like image 54
Asperi Avatar answered Oct 24 '22 15:10

Asperi


One solution is to use a Binding directly, which allows you to specify an explicit getter and setter:

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

You would then use it like so:

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

Although this works, it doesn't look as clean as using a computed property, and I'm not sure what the best way to create the Binding is? (I.e. using a function as in the example, using a get-only variable, or something else.)

like image 39
Senseful Avatar answered Oct 24 '22 13:10

Senseful