6

How to create a swiftui textfield that allows the user to only input numbers and a single dot? In other words, it checks digit by digit as the user inputs, if the input is a number or a dot and the textfield doesn't have another dot the digit is accepted, otherwise the digit entry is ignored. Using a stepper isn't an option.

Blazej SLEBODA
  • 8,936
  • 7
  • 53
  • 93
M.Serag
  • 1,381
  • 1
  • 11
  • 15
  • You can perhaps do it with a formatter although I haven't had much success with them. The method I use is to create an ObservableObject with a string, test the values in didSet and update if they don't fit your pattern. – Michael Salmon Sep 06 '19 at 18:11
  • Edited my answer with further details. – superpuccio Sep 08 '19 at 18:36

4 Answers4

10

SwiftUI doesn't let you specify a set of allowed characters for a TextField. Actually, it's not something related to the UI itself, but to how you manage the model behind. In this case the model is the text behind the TextField. So, you need to change your view model.

If you use the $ sign on a @Published property you can get access to the Publisher behind the @Published property itself. Then you can attach your own subscriber to the publisher and perform any check you want. In this case I used the sink function to attach a closure based subscriber to the publisher:

/// Attaches a subscriber with closure-based behavior.
///
/// This method creates the subscriber and immediately requests an unlimited number of values, prior to returning the subscriber.
/// - parameter receiveValue: The closure to execute on receipt of a value.
/// - Returns: A cancellable instance; used when you end assignment of the received value. Deallocation of the result will tear down the subscription stream.
public func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

The implementation:

import SwiftUI
import Combine

class ViewModel: ObservableObject {
    @Published var text = ""
    private var subCancellable: AnyCancellable!
    private var validCharSet = CharacterSet(charactersIn: "1234567890.")

    init() {
        subCancellable = $text.sink { val in
            //check if the new string contains any invalid characters
            if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
                //clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
                DispatchQueue.main.async {
                    self.text = String(self.text.unicodeScalars.filter {
                        self.validCharSet.contains($0)
                    })
                }
            }
        }
    }

    deinit {
        subCancellable.cancel()
    }
}

struct ContentView: View {
    @ObservedObject var viewModel = ViewModel()

    var body: some View {
        TextField("Type something...", text: $viewModel.text)
    }
}

Important to note that:

  • $text ($ sign on a @Published property) gives us an object of type Published<String>.Publisher i.e. a publisher
  • $viewModel.text ($ sign on an @ObservableObject) gives us an object of type Binding<String>

That are two completely different things.

EDIT: If you want you can even create you own custom TextField with this behaviour. Let's say you want to create a DecimalTextField view:

import SwiftUI
import Combine

struct DecimalTextField: View {
    private class DecimalTextFieldViewModel: ObservableObject {
        @Published var text = ""
        private var subCancellable: AnyCancellable!
        private var validCharSet = CharacterSet(charactersIn: "1234567890.")

        init() {
            subCancellable = $text.sink { val in                
                //check if the new string contains any invalid characters
                if val.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
                    //clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle)
                    DispatchQueue.main.async {
                        self.text = String(self.text.unicodeScalars.filter {
                            self.validCharSet.contains($0)
                        })
                    }
                }
            }
        }

        deinit {
            subCancellable.cancel()
        }
    }

    @ObservedObject private var viewModel = DecimalTextFieldViewModel()

    var body: some View {
        TextField("Type something...", text: $viewModel.text)
    }
}

struct ContentView: View {
    var body: some View {
        DecimalTextField()
    }
}

This way you can use your custom text field just writing:

DecimalTextField()

and you can use it wherever you want.

superpuccio
  • 11,674
  • 8
  • 65
  • 93
  • 2
    How to use in an existing app? i.e. DecimalTextField($myValue) ? – caram Oct 28 '19 at 16:59
  • @caram I made a [working example of that](https://stackoverflow.com/questions/59621625/numberfield-or-how-to-make-textfield-input-a-double-float-or-other-numbers-with). its not perfect and can be improved, but better then nothing. There I using it like this: `DecimalTextField("123", numericValue: $numeric)` – Aspid Jan 08 '20 at 12:40
  • 1
    Thanks so much for providing this very helpful answer, which taught me a lot about Combine and the SwiftUI use of pub/sub. I believe this approach can be simplified to remove explicit subscriptions and the resulting threading issues. See [this gist](https://gist.github.com/brotskydotcom/a5a41eec5419aaaef5e3aceb75e1921c) for a working example including usage examples. – brotskydotcom Feb 14 '20 at 06:55
2

This is a simple solution for TextField validation: (updated)

struct ContentView: View {
@State private var text = ""

func validate() -> Binding<String> {
    let acceptableNumbers: String = "0987654321."
    return Binding<String>(
        get: {
            return self.text
    }) {
        if CharacterSet(charactersIn: acceptableNumbers).isSuperset(of: CharacterSet(charactersIn: $0)) {
            print("Valid String")
            self.text = $0
        } else {
            print("Invalid String")
            self.text = $0
            self.text = ""
        }
    }
}

var body: some View {
    VStack {
        Spacer()
        TextField("Text", text: validate())
            .padding(24)
        Spacer()
    }
  }
}
FRIDDAY
  • 3,781
  • 1
  • 29
  • 43
0

I think using an async dispatch is the wrong approach and may cause other issues. Here's an implementation that achieves the same thing with a Double-backed property and manually iterates over the characters each time you type in the bound view.

final class ObservableNumber: ObservableObject {

    let precision: Int

    @Published
    var value: String {
        didSet {
            var decimalHit = false
            var remainingPrecision = precision
            let filtered = value.reduce(into: "") { result, character in

                // If the character is a number that by adding wouldn't exceed the precision and precision is set then add the character.
                if character.isNumber, remainingPrecision > 0 || precision <= 0 {
                    result.append(character)

                    // If a decimal has been hit then decrement the remaining precision to fulfill
                    if decimalHit {
                        remainingPrecision -= 1
                    }

                // If the character is a decimal, one hasn't been added already, and precision greater than zero then add the decimal.
                } else if character == ".", !result.contains("."), precision > 0 {
                    result.append(character)
                    decimalHit = true
                }
            }

            // Only update value if after processing it is a different value.
            // It will hit an infinite loop without this check since the published event occurs as a `willSet`.
            if value != filtered {
                value = filtered
            }
        }
    }

    var doubleValue: AnyPublisher<Double, Never> {
        return $value
            .map { Double($0) ?? 0 }
            .eraseToAnyPublisher()
    }

    init(precision: Int, value: Double) {
        self.precision = precision
        self.value = String(format: "%.\(precision)f", value)
    }
}

This solution also ensures you only have a single decimal instead of allowing multiple instances of ".".

Note the extra computed property to put it "back" into a Double. This allows you to continue to react to the number as a number instead of a String and have to cast/convert everywhere. You could very easily add as many computed properties as you want to react to it as Int or whatever numeric type as long as you convert it in the way you would expect.

One more note you could also make it generic ObservableNumber<N: Numeric> and handle different inputs but using Double and keeping the generics out of it will simplify other things down the road. Change according to your needs.

Dean Kelly
  • 598
  • 7
  • 13
0

Easy solution is to set .numberPad keyboardType:

  TextField(
     "0.0", 
     text: $fromValue
  )
  .keyboardType(UIKeyboardType.numberPad)
Tatsiana
  • 64
  • 1
  • 4