turingtested
turingtested

Reputation: 7154

How to subclass the @State property wrapper in SwiftUI

I have a @State variable that I that I want to add a certain constraint to, like this simplified example:

@State private var positiveInt = 0 {
    didSet {
        if positiveInt < 0 {
            positiveInt = 0
        }
    }
}

However this doesn't look so nice (it seems to be working though) but what I really want to do is to subclass or extend the property wrapper @State somehow so I can add this constraint in it's setter. But I don't know how to do that. Is it even possible?

Upvotes: 3

Views: 940

Answers (2)

Procrastin8
Procrastin8

Reputation: 4513

You can't apply multiple propertyWrappers, but you can use 2 separate wrapped values. Start with creating one that clamps values to a Range:

@propertyWrapper
struct Clamping<Value: Comparable> {
    var value: Value
    let range: ClosedRange<Value>

    init(wrappedValue value: Value, _ range: ClosedRange<Value>) {
        precondition(range.contains(value))
        self.value = value
        self.range = range
    }

    var wrappedValue: Value {
        get { value }
        set { value = min(max(range.lowerBound, newValue), range.upperBound) }
    }
}

Next, create an ObservableObject as your backing store:

class Model: ObservableObject {

    @Published
    var positiveValue: Int = 0

    @Clamping(0...(.max))
    var clampedValue: Int = 0 {
        didSet { positiveValue = clampedValue }
    }
}

Now you can use this in your content view:

    @ObservedObject var model: Model = .init()

    var body: some View {
        Text("\(self.model.positiveValue)")
            .padding()
            .onTapGesture {
                 self.model.clampedValue += 1
            }
    }

Upvotes: 2

superpuccio
superpuccio

Reputation: 13012

You can't subclass @State since @State is a Struct. You are trying to manipulate your model, so you shouldn't put this logic in your view. You should at least rely on your view model this way:

class ContentViewModel: ObservableObject {
    @Published var positiveInt = 0 {
        didSet {
            if positiveInt < 0 {
                positiveInt = 0
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var contentViewModel = ContentViewModel()

    var body: some View {
        VStack {
            Text("\(contentViewModel.positiveInt)")
            Button(action: {
                self.contentViewModel.positiveInt = -98
            }, label: {
                Text("TAP ME!")
            })
        }
    }
}

But since SwiftuUI is not an event-driven framework (it's all about data, model, binding and so forth) we should get used not to react to events, but instead design our view to be "always consistent with the model". In your example and in my answer here above we are reacting to the integer changing overriding its value and forcing the view to be created again. A better solution might be something like:

class ContentViewModel: ObservableObject {
    @Published var number = 0
}

struct ContentView: View {
    @ObservedObject var contentViewModel = ContentViewModel()

    private var positiveInt: Int {
        contentViewModel.number < 0 ? 0 : contentViewModel.number
    }

    var body: some View {
        VStack {
            Text("\(positiveInt)")
            Button(action: {
                self.contentViewModel.number = -98
            }, label: {
                Text("TAP ME!")
            })
        }
    }
}

Or even simpler (since basically there's no more logic):

struct ContentView: View {
    @State private var number = 0

    private var positiveInt: Int {
        number < 0 ? 0 : number
    }

    var body: some View {
        VStack {
            Text("\(positiveInt)")
            Button(action: {
                self.number = -98
            }, label: {
                Text("TAP ME!")
            })
        }
    }
}

Upvotes: 3

Related Questions