Search code examples
swiftswiftuicombine

Update upcoming values in sink if needed


I'm currently trying to modify an upcoming value from a textField which is using a Binding<Double>, but haven't found any working solution yet. It's only been infinite loops (Like the example below) and other solutions which didn't work in the end anyway. So, for example, if an user inputs an amount which is too low, I would want to change the upcoming value to the minimum and vice verse if the value is higher than the maximum value.

I also want to present the modified value (if needed) for the user, so I can't just store it in another variable.

Any ideas on how to solve this?

Example

class ViewModel: ObservableObject {
    @Published var amount: Double

    private var subscriptions: Set<AnyCancellable> = []
    private let minimum: Double = 10_000
    private let maximum: Double = 100_000

    init() {
        $amount
            .sink {
                if $0 < self.minimum {
                    // Set minimum value
                    self.amount = self.minimum
                } else if $0 > self.maximum {
                    // Set maximum value
                    self.amount = self.maximum
                }

                // If `Else` is implemented it will just be an infinite loop...
                else {
                    self.amount = $0
                }
            }
            .store(in: &subscriptions)
    }

    func prepareStuff() {
        // Start preparing
        let chosenAmount = amount
    }
}

Solution

  • One way is to use a property wrapper to clamp the values.

    Here is a very basic example of the issue, where we have an amount, that we can change to any value. The Stepper just makes it easy for input/testing:

    struct ContentView: View {
        @State private var amount = 0
    
        var body: some View {
            Form {
                Stepper("Amount", value: $amount)
    
                Text(String(amount))
            }
        }
    }
    

    The problem with this example is that amount isn't limited to a range. To fix this, create a Clamping property wrapper (partially from here):

    @propertyWrapper
    struct Clamping<Value: Comparable> {
        private var value: Value
        let range: ClosedRange<Value>
    
        var wrappedValue: Value {
            get { value }
            set { value = min(max(range.lowerBound, newValue), range.upperBound) }
        }
        var clampedValue: Value {
            get { wrappedValue }
            set { wrappedValue = newValue }
        }
    
        init(wrappedValue value: Value, _ range: ClosedRange<Value>) {
            precondition(range.contains(value))
            self.value = value
            self.range = range
        }
    }
    

    And then we can chain property wrappers, and get a working example where amount is limited:

    struct ContentView: View {
        @State @Clamping(-5 ... 5) private var amount = 0
    
        var body: some View {
            Form {
                Stepper("Amount", value: $amount.clampedValue)
    
                Text(String(amount))
            }
        }
    }
    

    I know, this isn't the proper way to limit a Stepper's range. Instead you should use Stepper(_:value:in:). However, this is to instead demonstrate clamping a value - not how to clamp a Stepper.

    What does this mean you need to do?

    Well, first off change your @Published property to this:

    @Published @Clamping(10_000 ... 100_000) var amount: Double
    

    And now you can just access amount like normal to get the clamped value. Use $amount.clampedValue like I did in my solution to get your Binding<Double> binding.


    If having troubles sometimes with compiling chained property wrappers (probably a bug), here is my example recreated using a Model object and @Published:

    struct ContentView: View {
        @StateObject private var model = Model(amount: 0)
    
        var body: some View {
            Form {
                Stepper("Amount", value: $model.amount.clampedValue)
    
                Text(String(model.amount.clampedValue))
            }
        }
    }
    
    class Model: ObservableObject {
        @Published var amount: Clamping<Int>
    
        init(amount: Int) {
            _amount = Published(wrappedValue: Clamping(wrappedValue: amount, -5 ... 5))
        }
    }