I\'m trying to get a numeric field updated so I\'m using a TextField with the formatter: parameter set. It formats the number into the entry field just fine, but does not up
Plan B. Since using value:
and NumberFormatter
doesn’t work, we can use a customised TextField
. I have wrapped the TextField
inside a struct
, so that you can use it as transparently as possible.
I am very new to both Swift and SwiftUI, so there is no doubt a more elegant solution.
struct IntField: View {
@Binding var int: Int
@State private var intString: String = ""
var body: some View {
return TextField("", text: $intString)
.onReceive(Just(intString)) { value in
if let i = Int(value) { int = i }
else { intString = "\(int)" }
}
.onAppear(perform: {
intString = "\(int)"
})
}
}
and in the ContentView:
struct ContentView: View {
@State var testInt: Int = 0
var body: some View {
return HStack {
Text("Number:")
IntField(int: $testInt);
Text("Value: \(testInt)")
}
}
}
Basically, we work with a TextField("…", text: …)
, which behaves as desired, and use a proxy text field.
Unlike the version using value:
and NumberFormatter
, the .onReceive
method responds immeditately, and we use it to set the real integer value, which is bound. While we’re at it, we check whether the text really yields an integer.
The .onAppear
method is used to fill the string from the integer.
You can do the same with FloatField
.
This might do the job until Apple finishes the job.
I know this has some accepted answers, but the above answers seem to have glitchy UX results when inputing values (at least for doubles). So I decided to write my own solution. It is largely inspired by the answers here so I would first try the other examples here before trying this one as it is a lot more code.
WARNING Although I have been an iOS developer for a long time, I'm fairly new to SwiftUI. So this is far from expert advice. I would love feedback on my approach but be nice. So far this has been working out well on my new project. However, I doubt this is as efficient as Apple's formatters.
protocol NewFormatter {
associatedtype Value: Equatable
/// The logic that converts your value to a string presented by the `TextField`. You should omit any values
/// - Parameter object: The value you are converting to a string.
func toString(object: Value) -> String
/// Once the change is allowed and the input is final, this will convert
/// - Parameter string: The full text currently on the TextField.
func toObject(string: String) -> Value
/// Specify if the value contains a final result. If it does not, nothing will be changed yet.
/// - Parameter string: The full text currently on the TextField.
func isFinal(string: String) -> Bool
/// Specify **all** allowed inputs, **including** intermediate text that cannot be converted to your object **yet** but are necessary in the input process for a final result. It will allow this input without changing your value until a final correct value can be determined.
/// For example, `1.` is not a valid `Double`, but it does lead to `1.5`, which is. Therefore the `DoubleFormatter` would return true on `1.`.
/// Returning false will reset the input to the previous allowed value.
/// - Parameter string: The full text currently on the TextField.
func allowChange(to string: String) -> Bool
}
struct NewTextField<T: NewFormatter>: View {
let title: String
@Binding var value: T.Value
let formatter: T
@State private var previous: T.Value
@State private var previousGoodString: String? = nil
init(_ title: String, value: Binding<T.Value>, formatter: T) {
self.title = title
self._value = value
self._previous = State(initialValue: value.wrappedValue)
self.formatter = formatter
}
var body: some View {
let changedValue = Binding<String>(
get: {
if let previousGoodString = self.previousGoodString {
let previousValue = self.formatter.toObject(string: previousGoodString)
if previousValue == self.value {
return previousGoodString
}
}
let string = self.formatter.toString(object: self.value)
return string
},
set: { newString in
if self.formatter.isFinal(string: newString) {
let newValue = self.formatter.toObject(string: newString)
self.previousGoodString = newString
self.previous = newValue
self.value = newValue
} else if !self.formatter.allowChange(to: newString) {
self.value = self.previous
}
}
)
return TextField(title, text: changedValue)
}
}
Then you can create a custom formatter for a Double
like this one:
/// An object that converts a double to a valid TextField value.
struct DoubleFormatter: NewFormatter {
let numberFormatter: NumberFormatter = {
let numberFormatter = NumberFormatter()
numberFormatter.allowsFloats = true
numberFormatter.numberStyle = .decimal
numberFormatter.maximumFractionDigits = 15
return numberFormatter
}()
/// The logic that converts your value to a string used by the TextField.
func toString(object: Double) -> String {
return numberFormatter.string(from: NSNumber(value: object)) ?? ""
}
/// The logic that converts the string to your value.
func toObject(string: String) -> Double {
return numberFormatter.number(from: string)?.doubleValue ?? 0
}
/// Specify if the value contains a final result. If it does not, nothing will be changed yet.
func isFinal(string: String) -> Bool {
return numberFormatter.number(from: string) != nil
}
/// Specify **all** allowed values, **including** intermediate text that cannot be converted to your object **yet** but are necessary in the input process for a final result.
/// For example, `1.` is not a valid `Double`, but it does lead to `1.5`, which is. It will allow this input without changing your value until a final correct value can be determined.
/// Returning false will reset the input the the previous allowed value. For example, when using the `DoubleFormatter` the input `0.1j` would result in false which would reset the value back to `0.1`.
func allowChange(to string: String) -> Bool {
let components = string.components(separatedBy: ".")
if components.count <= 2 {
// We allow an Integer or an empty value.
return components.allSatisfy({ $0 == "" || Int($0) != nil })
} else {
// If the count is > 2, we have more than one decimal
return false
}
}
}
To you can use this new component like this:
NewTextField(
"Value",
value: $bodyData.doubleData.value,
formatter: DoubleFormatter()
)
Here are a few other usages that I can think of:
/// Just a simple passthrough formatter to use on a NewTextField
struct PassthroughFormatter: NewFormatter {
func toString(object: String) -> String {
return object
}
func toObject(string: String) -> String {
return string
}
func isFinal(string: String) -> Bool {
return true
}
func allowChange(to string: String) -> Bool {
return true
}
}
/// A formatter that converts empty strings to nil values
struct EmptyStringFormatter: NewFormatter {
func toString(object: String?) -> String {
return object ?? ""
}
func toObject(string: String) -> String? {
if !string.isEmpty {
return string
} else {
return nil
}
}
func isFinal(string: String) -> Bool {
return true
}
func allowChange(to string: String) -> Bool {
return true
}
}
Inspired by above accepted proxy answer, here is a ready to use struct with fair amount of code. I really hope Apple can add an option to toggle the behavior.
struct TextFieldRow<T>: View {
var value: Binding<T>
var title: String
var subtitle: String?
var valueProxy: Binding<String> {
switch T.self {
case is String.Type:
return Binding<String>(
get: { self.value.wrappedValue as! String },
set: { self.value.wrappedValue = $0 as! T } )
case is String?.Type:
return Binding<String>(
get: { (self.value.wrappedValue as? String).bound },
set: { self.value.wrappedValue = $0 as! T })
case is Double.Type:
return Binding<String>( get: { String(self.value.wrappedValue as! Double) },
set: {
let doubleFormatter = NumberFormatter()
doubleFormatter.numberStyle = .decimal
doubleFormatter.maximumFractionDigits = 3
if let doubleValue = doubleFormatter.number(from: $0)?.doubleValue {
self.value.wrappedValue = doubleValue as! T
}
}
)
default:
fatalError("not supported")
}
}
var body: some View {
return HStack {
VStack(alignment: .leading) {
Text(title)
if let subtitle = subtitle, subtitle.isEmpty == false {
Text(subtitle)
.font(.caption)
.foregroundColor(Color(UIColor.secondaryLabel))
}
}
Spacer()
TextField(title, text: valueProxy)
.multilineTextAlignment(.trailing)
}
}
}
You can use Binding to convert Double<-->String for TextField
struct TestView: View {
@State var someNumber = 123.0
var body: some View {
let someNumberProxy = Binding<String>(
get: { String(format: "%.02f", Double(self.someNumber)) },
set: {
if let value = NumberFormatter().number(from: $0) {
self.someNumber = value.doubleValue
}
}
)
return VStack {
TextField("Number", text: someNumberProxy)
Text("number: \(someNumber)")
}
}
}
You can use computed property way to solve this issue. (thanks @ iComputerfreak)
struct TestView: View {
@State var someNumber = 123.0
var someNumberProxy: Binding<String> {
Binding<String>(
get: { String(format: "%.02f", Double(self.someNumber)) },
set: {
if let value = NumberFormatter().number(from: $0) {
self.someNumber = value.doubleValue
}
}
)
}
var body: some View {
VStack {
TextField("Number", text: someNumberProxy)
Text("number: \(someNumber)")
}
}
}
It seems while using value:
as an input, SwiftUI does not reload the view for any key that users tap on. And, as you mentioned, it reloads the view when users exit the field or commit it.
On the other hand, SwiftUI reloads the view (immediately) using text:
as an input whenever a key is pressed. Nothing else comes to my mind.
in my case, I did it for someNumber2
as below:
struct ContentView: View {
@State var someNumber = 123.0
@State var someNumber2 = "123"
var formattedNumber : NSNumber {
let formatter = NumberFormatter()
guard let number = formatter.number(from: someNumber2) else {
print("not valid to be converted")
return 0
}
return number
}
var body: some View {
VStack {
TextField("Number", value: $someNumber, formatter: NumberFormatter())
TextField("Number2", text: $someNumber2)
Text("number: \(self.someNumber)")
Text("number: \(self.formattedNumber)")
}
}
}