How to keep a @Published property normalized in a two-way binding ViewModel/TextField (i.e keeping in lowercase, remove links, etc)?

Viewed 102

What is the best practice or technique to keep a @Published property normalized? Supposed that we have a ViewModel which expose a @Published text property, which can be updated from the ViewModel / TextField (SwiftUI) and we want to remove any link that the user input in the field.

I wrote this example with test, but one of my issue is that on the subscription from the test, I'm receiving values in the wrong order (it sounds that mutating the text in the handleEvents is not so right)

class ViewModel: ObservableObject {
    @Published var text: String?
    private var cancellables = Set<AnyCancellable>()

    init() {
        setupObservers()
    }

    private func setupObservers() {
        $text
            .removeDuplicates() // use removeDuplicates to avoid infinite loop
            .compactMap({ $0 })
            .handleEvents(receiveOutput: { [weak self] in self?.cleanupLinks(from: $0) })
            .sink(receiveValue: { _ in })
            .store(in: &cancellables)
    }

    private func cleanupLinks(from text: String) {
        //dummy implementation
        self.text = text.replacingOccurrences(of: "https://any-url.com", with: "")
    }
}

// TEST
class SimpleReproTests: XCTestCase {
    func test_text_cleanupLinks() {
        let sut = ViewModel()

        let exp = expectation(description: "Wait for text updates")
        exp.expectedFulfillmentCount = 2
        var receivedValues = [String?]()
        let cancellable = sut.$text.dropFirst(2).sink {
            receivedValues.append($0)
            exp.fulfill()
        }
    
        sut.text = "Hello World! https://any-url.com"
        wait(for: [exp], timeout: 1.0)

        XCTAssertEqual(receivedValues, ["Hello World! https://any-url.com", "Hello World! "])
        cancellable.cancel()
   }
}

Right now, when running the test I'm receiving the values out of order (which ofc doesn't match the expectation):

["Hello World! ", "Hello World! https://any-url.com"]
1 Answers

There's no best way to do things ;)

Here's how I would do it, when the view's get more complicated than the most simple use cases:

Usually, you put all the logic into the view model. The view model may be implemented in various ways which is beyond this topic.

Basically, the view model receives an "event" optionally carrying additional information, for example the "to be modified" string whenever the user types and intends to modify the current string. Then the view model starts performing its logic based on this event and the current "state" managed by the view model. This results in a new "view state" (aka string in your example) which the view observes and renders accordingly. That way, the view model fully controls what the user sees and this is actual a representation of the current value of the "single source of truth".

In SwiftUI you can accomplish this using some parent view which will be initialised with the view model and observes it. In its body it passes the view state (or a relevant sub-state) to its child views. These child views receive the state holding it as a constant value. In addition the parent view passes in "action callbacks" which the child view calls on user actions, like typing a character or tapping a button:

struct MyView: View {
    let state: String
    let typing: (String) -> Void
    let dismiss: () -> Void

    var body: some View { ... }
}

The parent view wires up the child view's callback with the view model's actions. It never ever modifies the viewModel's viewState, too. Here's an example:

struct ParentView: View {
    @StateObject var viewModel = MyViewModel()

    var body: some View {
        MyView(state: viewModel.viewState.input,
               typing: { viewModel.send(.typing($0)) }, 
               dismiss: { viewModel.send(.dismiss) })
    }
}

That is, in order to wire up the views with the view model, the parent view just calls a function on the view model, i.e:

func send(_ action: Action)

where Action is purposefully an Enum:

enum Action {
    case typing(String) 
    case dismiss
}

So, your view model now will likely have some function that performs this "normalisation":

static func normalise(_ input: String) -> String

which is a synchronous, pure function (no side effects), which gets called whenever a typing action will be received. Since it's a pure function, testing is pretty easy, no mocks required.

Finally, the view model updates its state and generates a view state, which the view reacts upon accordingly (draw the single source of truth!). Since the viewState is a function of the model's state,

static func view(state: MyViewModel.State) -> ViewState

which is also a pure function, the view state can be easily tested as well.

Note, that there's no "two-way binding" through binding via the view state. Instead, the view state gets captured as a constant by the views which they render accordingly. View's don't perform logic and they send actions to the view model.

Views only (rarely) utilise @State variables when they have to manage private internal state.

Related