最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

swift - How to sanitise TextField's value during user input? - Stack Overflow

programmeradmin1浏览0评论

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
Add a comment  | 

2 Answers 2

Reset to default 0

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.

发布评论

评论列表(0)

  1. 暂无评论