0

I'm building a wrapper for a textField that is used to introduce quantities. I'm trying to build everything with Combine. One of the use cases consists in that if the stringValue sent by the text field has a letter, I filter the letters and reassign the new value to the same var, so the text field filters these values. There's also a code to change this value to an int so other components can read the int value. Here's the code:

class QuantityPickerViewModel: ObservableObject {
    private var subscriptions: Set<AnyCancellable> = Set<AnyCancellable>()
    @Published var stringValue: String = ""
    @Published var value : Int? = nil
    
    init(initialValue: Int?) {
        $stringValue
            .removeDuplicates()
            .print("pre-filter")
            .map {
                $0.filter {$0.isNumber}
            }
            .print("post-filter")
            .map {
                Int($0)
            }
            .assign(to: \.value, on: self)
            .store(in: &subscriptions)

        $value.map {
            $0 != nil ? String($0!): ""
        }
        .print("Value")
        .assign(to: \.stringValue, on:self)
        .store(in: &subscriptions)
    
        value = initialValue
    }
}

I verify the behavior using tests, I'll just the test that fails:

class QuantityPickerViewModelTest: AppTestCase {
    var model: QuantityPickerViewModel!
    override func setUpWithError() throws {
        super.setUp()
        model = QuantityPickerViewModel(initialValue: 10)
    }
    
    func test_changeStringValueWithLetters_filtersLettersAndChangesValue() {
        model.stringValue = "30a"
        
        XCTAssertEqual(model.value, 30)
        XCTAssertEqual(model.stringValue, "30") // fails saying stringValue is still "30a"
    }
}

The output of the test is:

Test Case '-[SourdoughMasterTests.QuantityPickerViewModelTest test_changeStringValueWithLetters_filtersLettersAndChangesValue]' started.
pre-filter: receive subscription: (RemoveDuplicates)
post-filter: receive subscription: (Print)
post-filter: request unlimited
pre-filter: request unlimited
pre-filter: receive value: ()
post-filter: receive value: ()
Value: receive subscription: (PublishedSubject)
Value: request unlimited
Value: receive value: ()
Value: receive value: (10)
pre-filter: receive value: (10)
post-filter: receive value: (10)
Value: receive value: (10)
pre-filter: receive value: (30a)
post-filter: receive value: (30)
Value: receive value: (30)
pre-filter: receive value: (30)
post-filter: receive value: (30)
Value: receive value: (30)
/Users/jpellat/workspace/SourdoughMaster/SourdoughMasterTests/QuantityPickerViewModelTest.swift:54: error: -[SourdoughMasterTests.QuantityPickerViewModelTest test_changeStringValueWithLetters_filtersLettersAndChangesValue] : XCTAssertEqual failed: ("30a") is not equal to ("30")

Does anyone know why the value has not been assigned? Thanks

Jpellat
  • 907
  • 7
  • 29
  • 2
    I don’t get why you’ve configured a circular pair of pipelines between two publishers? – matt Mar 21 '21 at 22:14
  • Basivally I'm experimenting and trying to understand the framework. But here's use cases I'm trying to solve: - When a string value is set, value changes to be an int representation of this value - When value is set, string value should be overwritten with the string representation of this value - if in the string there's a character that is not a number filter it The idea is that string value is for the textfield binding, and the value is what other viewModels depend to read the result. – Jpellat Mar 21 '21 at 22:27
  • So basically like https://stackoverflow.com/questions/58733003/swiftui-how-to-create-textfield-that-only-accepts-numbers ? – matt Mar 21 '21 at 22:39
  • Oh thanks for the link but no, that's just to show the number pad, you can still paste other stuff that are not numbers. I can also solve this concrete issue following this post https://programmingwithswift.com/numbers-only-textfield-with-swiftui/ But my real question here is what is the combine concept I don't understand that makes this code have a an unexpected behavior for me? My expectation is for this code to work, why it doesn't? The problem of the textfield and numbers is simple enough there's multiple ways to work it around, but I'm interested to learn wnat I'm doing wrong here – Jpellat Mar 21 '21 at 23:43
  • 2
    You're looking at a bad answer. Look at the _good_ answers. — As for what you're doing wrong, you need to listen to my initial question. You seem to expect that the pipeline will magically come round and circularly change the binding after the fact. – matt Mar 21 '21 at 23:57
  • Haha, I don't think looking at this as a good answer or a bad answer is the point, this problem can be solved using other approaches, I agree. And they may be simpler, I can agree. But I don't think there's anything bad on constraining a solution to a framework if you're trying to learn. Anyway thanks for the help :) – Jpellat Mar 22 '21 at 01:00

2 Answers2

1

It's not a Combine issue per-se that causes this, but it seems that a Published publisher emits before the value is actually set on the property. So, basically the "30a" is overwriting whatever is set in the assign.

In any case, this circular pipeline chain seems a bit fishy. I also don't think you actually need Combine here at all - it could just be solved with two computed properties and a common stored property:

@Published 
private var _value: Int? = nil

var value: Int? {
   get { _value }
   set { _value = newValue }
}

var stringValue: String {
   get { _value?.description ?? "" }
   set {
      _value = Int(newValue.filter { "0"..."9" ~= $0 })
   }
}
New Dev
  • 48,427
  • 12
  • 87
  • 129
  • I don’t see how this validates and filters the text field as the user types. – matt Mar 22 '21 at 00:49
  • @matt, I guess I wasn't thinking about any TextField. I know the OP mentions it, but if there's no code, I don't want to waste time guessing. I was illustrating that there was no need to use Combine here to achieve what I think the OP wanted, which is that setting `stringValue` filters out non-number characters. Have you understood their question differently? – New Dev Mar 22 '21 at 00:54
  • Awesome! Understand why it was not working was actually my main goal. I got to the same conclusion; Publishers are triggered on willSet, so anything you do to the variable as a side effect of it is overwritten. I agree to combine is not needed, but I think it is still a fair question to ask how we can do that with Combine. Thanks for the answer! – Jpellat Mar 22 '21 at 00:57
  • 2
    The thing is that the entire problem of validate-and-filter-a-text-field works so oddly on SwiftUI, because there is no UITextFieldDelegate method that make it all so easy the way it does in UIKit. – matt Mar 22 '21 at 01:27
  • 3
    Note that `isNumber` would validate all kind of numeric characters including fractions like ½, ↉, ⅓, ⅔, ¼, ¾, ⅕, ⅖, ⅗, ⅘, ⅙, ⅚, ⅐, ⅛, ⅜, ⅝, ⅞, ⅑. Better to be more restrictive and use a predicate like `"0"..."9" ~= $0`. – Leo Dabus Mar 22 '21 at 04:09
  • @LeoDabus - good point. I just lazily copied what OP has done, but this is the correct way to do this. – New Dev Mar 22 '21 at 11:52
0

Even if I consider New Dev's solution better, and hence the official answer, I post my Combine solution in case someone is curious. I Basically eliminate the loop by separating user input and user output. To the interface, it looks the same but I can create a userInput -> value -> user output structure:

class QuantityPickerViewModel: ObservableObject {
    private var subscriptions: Set<AnyCancellable> = Set<AnyCancellable>()
    var stringValue: String {
        get {
            userOutput
        }
        set {
            userInput = newValue
        }
    }
    @Published var value : Int? = nil
    @Published private var userInput: String = ""
    private var userOutput: String = ""
    
    init(initialValue: Int?) {
        $userInput
            .map {
                $0.filter {$0.isNumber}
            }
            .map {
                Int($0)
            }
            .assign(to: \.value, on: self)
            .store(in: &subscriptions)
        
        $value
            .map {
                $0 == nil ? "": String($0!)
            }
            .assign(to: \.userOutput, on: self)
            .store(in: &subscriptions)
        
        value = initialValue
    }
}

Also here are the tests in case you're curious about the specs:

class QuantityPickerViewModelTest: XCTestCase {
    var model: QuantityPickerViewModel!
    override func setUpWithError() throws {
        super.setUp()
        model = QuantityPickerViewModel(initialValue: 10)
    }

    func test_initWith10_valueAfterInit_is10() {
        XCTAssertEqual(model.value, 10)
        XCTAssertEqual(model.stringValue, "10")
    }

    func test_initWithNil_valueAfterInit_isNilAndEmptyString() {
        model = QuantityPickerViewModel(initialValue: nil)
        XCTAssertNil(model.value)
        XCTAssertEqual(model.stringValue, "")
    }
    
    func test_changeStringValue_changesValue() {
        model.stringValue = "20"
        
        XCTAssertEqual(model.value, 20)
        XCTAssertEqual(model.stringValue, "20")
    }
    
    func test_changeValue_changesStringValue() {
        model.value = 20
        
        XCTAssertEqual(model.value, 20)
        XCTAssertEqual(model.stringValue, "20")
    }
    
    func test_changeValueToNil_changesStringValueToEmpty() {
        model.value = nil
        
        XCTAssertEqual(model.value, nil)
        XCTAssertEqual(model.stringValue, "")
    }
    
    func test_changeStringValueWithLetters_filtersLettersAndChangesValue() {
        model.stringValue = "30a"
        
        XCTAssertEqual(model.value, 30)
        XCTAssertEqual(model.stringValue, "30")
    }
}
Jpellat
  • 907
  • 7
  • 29
  • 2
    That's better; you are starting to understand the point I made in my first comment. But the question remains why you need two pipelines. This is un-Combine-like. If the job of the first pipeline is merely to assign to a publisher that triggers the second pipeline, why isn't this a single pipeline? – matt Mar 22 '21 at 02:15