I have a TextField where user enters some data. For example, phone number. If user enters something starting with 8 I remove 8
Originally I had such an implementation:
final class ViewModel: ObservableObject { // minimal reproducible example
    @Published var input = "" {
        didSet {
            if input.hasPrefix("8") {
                input = String(input.dropFirst())
            }
        }
    }
}
struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()
    var body: some View {
        TextField(
            "",
            text: $viewModel.input,
            prompt: Text("My cats don't like 8")
        )
        .padding()
        .background(Color.gray)
    }
}
The problem I noticed now is that it only works for iOS 16 or, probably, below. While didSet gets invoked and the value is changed (if you add a print(input) in didSet you'll see that all the 8s are removed) the value visible to the user, the one in the UI part of TextField will not change.
I found one solution, namely move the sanitisation to observation in the view:
final class ViewModel: ObservableObject {
    @Published var input = ""
    func processInput(_ newValue: String) { // new function to call from the view
        if newValue.hasPrefix("8") {
            input = String(newValue.dropFirst())
        }
    }
}
struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()
    var body: some View {
        TextField(
            "",
            text: $viewModel.input,
            prompt: Text("8s are so round and nice")
        )
        .onChange(of: viewModel.input) { oldValue, newValue in // observation 
            viewModel.processInput(newValue)
        }
        .padding()
        .background(Color.gray)
    }
}
But it does not look optimal from code perspective. We already have a Published property and having another handle feels redundant. So my question is
Do you have an idea how to sanitise user input while it's being entered?
Other notes
Also tried subscribing to Published in ViewModel and doing the operation in willSet. The first one behaves as didSet, willSet leads to infinite recursion
final class ViewModel: ObservableObject {
    @Published var input = "" {
        willSet {
            if input.hasPrefix("8") {
                input = String(input.dropFirst())
            }
        }
    }
}
/// and 
final class ViewModel: ObservableObject {
    @Published var input = ""
    private var cancellables = Set<AnyCancellable>()
    init() {
        self.input = input
        $input.sink { [weak self] newInput in
            if newInput.hasPrefix("8") {
                self?.input = String(newInput.dropFirst())
            }
        }
        .store(in: &cancellables)
    }
}
I have a TextField where user enters some data. For example, phone number. If user enters something starting with 8 I remove 8
Originally I had such an implementation:
final class ViewModel: ObservableObject { // minimal reproducible example
    @Published var input = "" {
        didSet {
            if input.hasPrefix("8") {
                input = String(input.dropFirst())
            }
        }
    }
}
struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()
    var body: some View {
        TextField(
            "",
            text: $viewModel.input,
            prompt: Text("My cats don't like 8")
        )
        .padding()
        .background(Color.gray)
    }
}
The problem I noticed now is that it only works for iOS 16 or, probably, below. While didSet gets invoked and the value is changed (if you add a print(input) in didSet you'll see that all the 8s are removed) the value visible to the user, the one in the UI part of TextField will not change.
I found one solution, namely move the sanitisation to observation in the view:
final class ViewModel: ObservableObject {
    @Published var input = ""
    func processInput(_ newValue: String) { // new function to call from the view
        if newValue.hasPrefix("8") {
            input = String(newValue.dropFirst())
        }
    }
}
struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()
    var body: some View {
        TextField(
            "",
            text: $viewModel.input,
            prompt: Text("8s are so round and nice")
        )
        .onChange(of: viewModel.input) { oldValue, newValue in // observation 
            viewModel.processInput(newValue)
        }
        .padding()
        .background(Color.gray)
    }
}
But it does not look optimal from code perspective. We already have a Published property and having another handle feels redundant. So my question is
Do you have an idea how to sanitise user input while it's being entered?
Other notes
Also tried subscribing to Published in ViewModel and doing the operation in willSet. The first one behaves as didSet, willSet leads to infinite recursion
final class ViewModel: ObservableObject {
    @Published var input = "" {
        willSet {
            if input.hasPrefix("8") {
                input = String(input.dropFirst())
            }
        }
    }
}
/// and 
final class ViewModel: ObservableObject {
    @Published var input = ""
    private var cancellables = Set<AnyCancellable>()
    init() {
        self.input = input
        $input.sink { [weak self] newInput in
            if newInput.hasPrefix("8") {
                self?.input = String(newInput.dropFirst())
            }
        }
        .store(in: &cancellables)
    }
}
I'm afraid .onChange(of: viewModel.input) is the most reliable solution so far. It can be workaround in other way - try to add small delay before dropping the "8" like so:
import SwiftUI
final class ViewModel: ObservableObject { // minimal reproducible example
    @Published var input = "" {
        didSet {
            if input.hasPrefix("8") {
                DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(30)) { [weak self] in
                    guard let self,
                          self.input.hasPrefix("8") else {
                        return
                    }
                    self.input = String(self.input.dropFirst())
                }
            }
        }
    }
}
struct ContentView: View {
    @StateObject var viewModel: ViewModel = ViewModel()
    
    var body: some View {
        TextField(
            "",
            text: $viewModel.input,
            prompt: Text("My cats don't like 8")
        )
        .padding()
        .background(Color.gray)
    }
}
Even though it does work, I would say it still does not look optimal from code perspective. Obviously there will be delay and user will actually see "8" was added to the TextField before it disappeared. Even worse - it will not work if delay is too short.
At the end of the day, Apple provides such example:
You can use onChange to trigger a side effect as the result of a value changing, such as an Environment key or a Binding.
struct PlayerView: View {
    var episode: Episode
    @State private var playState: PlayState = .paused
    var body: some View {
        VStack {
            Text(episode.title)
            Text(episode.showTitle)
            PlayButton(playState: $playState)
        }
        .onChange(of: playState) { oldState, newState in
            model.playStateDidChange(from: oldState, to: newState)
        }
    }
}
In our case $viewModel.input is Binding so launching side effect to sanitize itself should be perfectly fine.
Yet another approach would be to try move onChange(of:) logic into ViewModel. For example:
import SwiftUI
import Combine
final class ViewModel: ObservableObject { // minimal reproducible example
    @Published var input = ""
    var token: AnyCancellable? = nil
    
    init() {
        token = $input.sink(receiveValue: { [weak self] newValue in
            if newValue.hasPrefix("8") {
                self?.input = String(newValue.dropFirst())
            }
        })
    }
}
However it still will fail to do the job. Another workaround would be to add really small delay to the publisher like so:
init() {
    token = $input
        .debounce(for: 1, scheduler: RunLoop.main)
        .sink(receiveValue: { newValue in
        if newValue.hasPrefix("8") {
            self.input = String(newValue.dropFirst())
        }
    })
}
Finally it will work as well. But again, using magic numbers is a code smell. It seems we are really locked in to use onChange(of:).
Try @State and a computed binding, e.g.
struct ContentView: View {
    @State var input = ""
    var sanitizedInput: Binding<String> {
        Binding {
            input
        } set: { newValue in
            if newValue.hasPrefix("8") {
                input = String(newValue.dropFirst())
            }
            else {
                input = newValue
            }
        }
    }
    var body: some View {
        TextField(
            "",
            text: sanitizedInput,
            prompt: Text("8s are so round and nice")
        )
        .padding()
        .background(Color.gray)
    }
}
FYI @StateObject is usually only for doing something asynchronous like a delegate or closure. .task(id:) is a replacement for @StateObject. .onChange is usually for external actions not for connecting up states because you'll get consistency errors, need to learn Binding instead.

