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)
}
}
Share
Improve this question
edited Jan 29 at 10:23
DarkBee
15.6k8 gold badges72 silver badges117 bronze badges
asked Jan 29 at 10:22
Pavel StepanovPavel Stepanov
9578 silver badges17 bronze badges
2
- Create your own Formatter – lorem ipsum Commented Jan 29 at 10:47
- @loremipsum thanks for your suggestions. Using formatting itself feels kinda hacky. We have some string, value in input. We sanitise it and use for the further communication, ie with BE. But then we separately process the "displayed" input via formatter. That means there are two separate implementations. If there is a bug in one then what user sees is different to what data contains. Although formatting is definitely a good hint anyway, ie if I'd just need to add extra characters that do not influence the value underneath (spaces, braces in number input) it would fit my needs – Pavel Stepanov Commented Feb 5 at 8:53
2 Answers
Reset to default 0I'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.