TextField format, integer limits and fractions not applied

Hi Apple-Team,

the format of a text field, in my case a percentage with one decimal place, is not applied when a button is pressed and the view is exited using dismiss. It is only used when another field receives focus. A button is not a focusable object, and this is a problem. This allows you to specify more decimal places, resulting in a saved value with unwanted decimal places.

It's even worse when you specify integer limits and the value exceeds the limit.

Do you always have to check the value before saving it? What's the point of having a format then?

Some example images: The items in the list have a fraction limit of 8.

The field has integer limit 2 and 1 fraction.

When focusing a different field the format is applied.

Here the code I used:


struct Item: Identifiable, Hashable {
    var id: Int
    var value: Double
}


struct WrongFractionsListView: View {
    @State private var items: [Item] = [
        Item(id: 1, value: 0.225),
        Item(id: 2, value: 0.377),
        Item(id: 3, value: 0.241)]
    
    enum ItemTransfer: Hashable {
        case new
    }
    
    var body: some View {
        List {
            ForEach(items) { item in
                HStack {
                    Text("\(item.id)")
                    Spacer()
                    Text("\(item.value, format: .percent.precision(.fractionLength(8)))")
                }
            }
        }
        .navigationDestination(for: ItemTransfer.self) { _ in
            WrongFractionsEditorView(items: $items)
        }
        .toolbar{
            ToolbarItem(placement: .topBarTrailing) {
                NavigationLink(value: ItemTransfer.new) {
                    Image(systemName: "plus")
                }
            }
        }
    }
}

#Preview {
    NavigationStack {
        WrongFractionsListView()
    }
}

struct WrongFractionsEditorView: View {
    @Environment(\.dismiss) var dismiss
    @State private var percentValue: Double = 0
    @State private var testValue: String = ""
    
    @Binding var items: [Item]
    
    var body: some View {
        Form {
            TextField("(%)", value: $percentValue, format: .percent.precision(.integerAndFractionLength(integerLimits: 0...2, fractionLimits: 0...1)))
                .keyboardType(.decimalPad)
            TextField("Just for Focus", text: $testValue)
        }
        .navigationTitle("Percent Fractions")
        .toolbar{
            ToolbarItem(placement: .topBarTrailing) {
                Button {
                    saveAndClose()
                } label: {
                    Image(systemName: "checkmark")
                }
            }
        }
    }
    
    private func saveAndClose() {
        let max = items.max { $0.id < $1.id}!.id
        let item: Item = .init(id: max+1, value: percentValue)
        items.append(item)
        dismiss()
    }
}

#Preview {
    @Previewable @State var items: [Item] = [
        Item(id: 1, value: 0.225),
        Item(id: 2, value: 0.377),
        Item(id: 3, value: 0.241)]
    NavigationStack {
        WrongFractionsEditorView(items: $items)
    }
}

How can I fix this?

Thank you Christian

I have a solution I've already thought of, but I'm not satisfied with it because I would have to implement it everywhere this problem occurs. At least the onChange() method in the View. Before posting the answer here, I was able to identify the bug more precisely. More about this at the end of the post.

Extension on Double for rounding:

extension Double {
    func rounded(toPlaces places: Int, rule: FloatingPointRoundingRule = .down) -> Double {
        let divisor = pow(10.0, Double(places))
        return (self * divisor).rounded(rule) / divisor
    }
}

A max percent value in the View:

private let maxPercent = 0.8

onChange of the value:

.onChange(of: percentValue) {
            checkPercentValue()
        }

The check function:

private func checkPercentValue() {
        let mf = modf(percentValue).1
        var val = mf.rounded(toPlaces: 3)
        if val > maxPercent {
            val = maxPercent
        }
        percentValue = val
    }

Not good, but it seems to work.

Yes, at first glance it seems to work, but when I enter values ​​above 100 it no longer works.

The function checkPercentValue() works correctly and sets percentValue to 0.0, but the field does not respond to this. The problem is:

.percent.precision(.integerAndFractionLength(integerLimits: 0...2, fractionLimits: 0...1))

Simply using .percent, however, works.

Christian

I wrote a separate field component to solve the percent field problem. Apparently, this is generally necessary with decimal numbers. Or is there a simpler solution?

Decimal point formatting is a general problem and affects not only percentages but all floating-point numbers. Whether format or formatter is used is irrelevant.

I have a formatter that only allows values ​​up to a maximum and has one decimal place.

let limitedFormatter: NumberFormatter = {
    let formatter = NumberFormatter()
    formatter.minimum = 0
    formatter.maximum = 13.9
    formatter.generatesDecimalNumbers = true
    formatter.maximumFractionDigits = 1
    formatter.numberStyle = .decimal
    return formatter
}()

When the focus of the field is removed, the formatter becomes active and the behavior is as expected. However, if the view is immediately exited using dismiss(), as in the previous example, it doesn't work.

That's not entirely correct, because the formatter only does half its job and checks whether the maximum has been exceeded, but ignores the decimal places, as in the other example.

However, it seems to work if you append arbitrary decimal places to the maximum value. Example: the maximum is 13.9, and if I enter 13.9888888, it becomes 13.9.

It gets even stranger: if you set a minimum value in the formatter and then enter a value outside that range, the result is nil. With a minimum value of 0, the first digit is used, as long as it's not higher than the maximum value; otherwise, the result is nil again.

Christian

TextField format, integer limits and fractions not applied
 
 
Q