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.