xmartlabs / Eureka

Elegant iOS form builder in Swift
https://eurekacommunity.github.io
MIT License
11.77k stars 1.33k forks source link

DecimalRow Editing #1451

Open daveparks23 opened 6 years ago

daveparks23 commented 6 years ago

If I re-select a field (Decimal Row) that is blank AND a validation error of required has been triggered. The first digit I press gives me a decimal and a 0 after it. I press 1 but it gives me 1.0 AND when I keep typing it gives me 1.01 Very weird and confusing. I want normal editing. I don't want the "auto" decimal showing up. I just need to check for 2 decimal places after I click away from the field. What am I missing? Can I create a 'pattern' type validation like javascript for numbers? Maybe just using the textField row. Thanks!

daveparks23 commented 6 years ago

I have narrowed down that it occurs only when a validation rule has been broken. When row is invalid.

mats-claassen commented 6 years ago

Do you have useFormatterDuringInput enabled? Try setting it to false?

daveparks23 commented 6 years ago

@mats-claassen Thanks for the reply! I solved the (to me at least) unexpected behavior by .validateOnDemand and I run the row.validate() method on onCellHighlightedChange but I don't have it run the first time I touch the cell. .onCellHighlightedChanged({ (cell, row) in if self.touchCount > 0 { row.validate() } else { self.touchCount = 1 } I still don't understand why... but with the above code I do not get the unexpected behavior. Thanks!

daveparks23 commented 6 years ago

@mats-claassen How can I have a 'DecimalRow' but not allow it to format the number ever. No auto formatting. I can manually check to see if enough decimals have been placed using a Rule Closure and .significantFractionalDecimalDigits. Thanks!

mats-claassen commented 6 years ago

You could try setting the row's formatter to nil but I am not sure that would work

CutFlame commented 6 years ago

I had this same issue and fixed it this way:

        row.validationOptions = ValidationOptions.validatesOnDemand
        row.useFormatterOnDidBeginEditing = false
        row.useFormatterDuringInput = false
        row.formatter = nil
daveparks23 commented 6 years ago

Setting row.formatter = nil does not keep the row from adding a .0 to an interger. I put 87 but when I touch away it turns it into 87.0. I just want 87 I need my field to be required to be a number, with or without a decimal place. Thanks for your help. ZipCode row does not work because it allows text.

makeitTim commented 6 years ago

Still having this problem. @CutFlame's fix does not work for me, it's still difficult to re-edit a field because it keeps sticking in the period at a weird spot.

This input is for a (localized) money amount. Does anybody know how to setup a DecimalRow as a working money input?

mats-claassen commented 6 years ago

Have you tried using the CurrencyFormatter as in the examples? Also if you want a row without decimal places you can use IntRow with a Formatter for the money sign.

makeitTim commented 6 years ago

I tried the CurrencyFormatter from the examples, but it also was buggy with the period and doesn't allow formatting how I wanted.

In the end I got it working how I wanted using DecimalFormatter with the following settings. RuleRequired() seems to affect when the formatting runs. How it works now is you just enter numbers start with the cents, so typing 2845 is $28.45.

let formatter = DecimalFormatter()
formatter.locale = .current
formatter.numberStyle = .currency
formatter.currencySymbol = "$"  // setting $ prevents including currency code ie. US, CAD

row.formatter = formatter
row.useFormatterDuringInput = true
row.useFormatterOnDidBeginEditing = true

// validation rules
row.add(rule:RuleRequired())
row.add(rule:RuleGreaterThan(min: 0.0))
row.validationOptions = .validatesOnChange
}.cellSetup { cell, _  in
    cell.textField.keyboardType = .numberPad
}

Note I set to always use just $ no matter the locale, others probably wouldn't want this.

leojkwan commented 6 years ago

This was very frustrating for me too— I ran into the same exact issue building a decimal keypad for user bodyweight input.

dleute commented 5 years ago

Anyone ever fix this? I just posted a new issue going through showing the various ways the behavior is unexpected.

dleute commented 5 years ago

I have confirmed that the adding a .0 behavior happens only when validation is present. I will experiment if it only happens with some validation rules and not others.

This seems quite odd as it implies that validation is changing the value of the row in some circumstance. Which would cause the formatter to flip out on beginning the edit of the row?

dleute commented 5 years ago

The problem is RuleRequired. It seems that validation rules must match the type of value of the cell to function. Interesting restriction.

So, RuleGreaterOrEqualThan(min: 0.0)) and RuleGreaterThan(min: 0.0)) both solve the problem.

In this case RuleGreaterOrEqualThan validates for an empty field or a 0. RuleGreaterThan does not.

Interestingly, if you type a minus then a number, the appending of the .0 happens again. Meaning, whenever the field is viewed as invalid by any validation rule appending .0 happens. So if I set my minimum to anything above 1 (say 2) and I type 1, then the .0 is appended.

This is all happening with validation on change. If I do validationOnBlur, then if I enter the field when invalid and type something, .0 is appended. Crazy.

In my case RuleGreaterOrEqualThan(min: 0.0) is sufficient. And if someone is typing something negative (which they can't do with the keypad provided) they are really using it wrong.

However, it doesn't seem like a Validation Rule should have any effect on content.

I opened #1819 showing many of the ways adding validation broke the DecimalRow. It seems like many of the formatter issues could be closed if validation didn't break the field.

dleute commented 5 years ago

https://github.com/xmartlabs/Eureka/blob/3cdd6d3947ba37d145a5d7429a950e08478e9d4e/Source/Rows/Common/FieldRow.swift#L360

If I were to guess, the problem is in that function. There are a couple of red flags to me in there that could be creating issues.

But, I think the biggest one is the way it defaults to converting a string to a Double. Whenever this is done a double always appends .0 to an integer. It's still not clear to me how that value is making it's way back to the textField, but it is. Basically, if you type any digit, it converts it to a float with .0 appended.

It's also not clear to me why it works correctly the first time, but never beyond that. I think this has something to do with the value setter. The only consistent workaround I can see is to make DecimalRow actually store things as strings which numberFormatters and other converters can convert to without appending the .0. But any double holding an int in swift shows with a .0 when logged.

Basically, internally convert string to numbers as necessary, but always store as strings in the value property. Ideally as the simplest unformatted string possible. No delimiters. It's the job of the formatter to convert that value to a friendly representation. (Be it monetary, decimal, scientific, whatever). I believe this would also simplify the field rows as most of them would simply be specifically configured formatters paired with a specific keyboard selection.

I may try a pull request for this depending on how hard of a job it is.

dleute commented 5 years ago

I figured out how this is happening. Validation triggers a cellUpdate to reconfigure the cell to display validation errors. This process takes the current row.value and sets it back into the UITextField.text variable.

When this happens it does not go through the UITextField did change process. Meaning the "raw" value is immediately displayed. When you type "1", it is stored as "1.0" in the float. Validation changes (in generating an error, or removing errors) trigger a cellUpdate (with validatesOnChange) and set that "1.0" to the textField.text property. Since editing is still in progress, the value isn't sanitized by any formatter. Technically, it doesn't have to be a validation change, anything that triggers a cellUpdate will cause the issue.

I think I found the fix. Going to submit a pull request. It is surprisingly simple.

dleute commented 5 years ago

1821 is submitted. Still testing to see if it breaks other scenarios. To actually solve the problem it will generally require a formatter to be present. Otherwise it just uses the raw value and the problem continues.

im-jersh commented 8 months ago

the only way i've managed to easily implement formatting behavior to exclude the trailing .0 of an integer value in a DecimalRow is to alter the row's associated number formatter on the fly in onCellHighlightChanged.

// Create and configure the number formatter
let numberFormatter: NumberFormatter = NumberFormatter()

// Save the original configuration values for any formatter properties we'll change below
let originalNumberStyle = numberFormatter.numberStyle
let originalMaximumFractionDigits = numberFormatter.maximumFractionDigits
let originalMinimumFractionDigits = numberFormatter.minimumFractionDigits
let originalUsesGroupingSeparator = numberFormatter.usesGroupingSeparator

// Create and configure the DecimalRow 
let decimalRow = DecimalRow() { decimalRow in
    decimalRow.formatter = numberFormatter
}
.onCellHighlightChanged { cell, row in
    // The following logic is to provide a UX enhancement, specifically for
    // number fields containing an integer value.
    //
    // Regardless of the field's formatting configuration, we want to make editing
    // integer values easier and more intuitive. When the following is true:
    // 1. the row has a value
    // 2. that value is an integer
    // we want the value shown in the text field to show only the integer, discarding
    // the extraneous trailing decimal and zero(s). This formatting should ONLY apply
    // to the existing value immediately after the user focuses this number field,
    // NOT during the entire editing/focus of this field.
    guard let value = row.value else { return }

    let underlyingDoubleValueIsInteger = value.isEqual(to: value.rounded(.up))

    // Important: If the underlying value represents an integer, format the number with
    // with the formatter but only when the user starts editing.
    row.useFormatterOnDidBeginEditing = underlyingDoubleValueIsInteger

    // If the user has just tapped into this row and the value represents and integer,
    // update the formatter to force the formatted value to drop the decimal and 
    // fractional digits which should all be zeros
    if row.isHighlighted && underlyingDoubleValueIsInteger {
        numberFormatter.numberStyle = .decimal
        numberFormatter.maximumFractionDigits = 0
        numberFormatter.minimumFractionDigits = 0
        // Important: Don't include the grouping separators such as commas
        // otherwise the formatted value will include them and Eureka won't be able
        // to convert the cell text field's text back to a number.
        numberFormatter.usesGroupingSeparator = false
    } else {
        // Set the row's formatter back to its original configuration once the row
        // loses focus, which will apply the formatting/validation that we'd
        // expect after the fact.
        numberFormatter.numberStyle = originalNumberStyle
        numberFormatter.maximumFractionDigits = originalMaximumFractionDigits
        numberFormatter.minimumFractionDigits = originalMinimumFractionDigits
        numberFormatter.usesGroupingSeparator = originalUsesGroupingSeparator
    }
}

it's really hacky but the only real alternatives would be to submit a PR that overrides DecimalCell.textFieldDidChange where you'd then be able to sanitize the text field's string of any symbols that the formatter might have applied (due to row.useFormatterOnDidBeginEditing = true`) before attempting to use the formatter to convert the text to a number and set the row's value as such.

another alternative, but one that i have yet to invest any time (or sanity) into, would be to subclass NumberFormatter and override editingString(for obj: Any) -> [String]. i'd imagine that since all we really care about is removing the trailing fractional value of an integer when the user taps into the field, you could could add this logic to that overridden method. you'd still have to set row.useFormatterOnDidBeginEditing = true so the formatter is used when the user taps on the row. but my guess is that this route probably leads to just as many headaches since you'd have to account for things like grouping separators and if the user purposely added the decimal separator.