Expected behaviour iOS14 vs iOS15 - Bound text in TextField goes out of sync with source of truth value on iOS15 Xcode Beta 2

Hi,

So in iOS14, I've been using a code pattern to listen for changes in TextField's text bound value and to reject/drop unwanted changes and values.

In iOS15, this pattern no longer seems to work , but I've not found others complaining about it, so I'm now wondering is iOS15's behaviour a bug, or just the expected behavior?

A simplified example of this type of code pattern is shown below which when run on:

  • iOS14 effectively disables the use of the "3" button.
  • iOS15 Xcode Beta 2 does not, instead even though source of truth "textFieldText" reflects the expected value, the TextField displays what is typed regardless.

I've put FB9290496 , thoughts etc welcome.

Thanks

import SwiftUI

class No3ViewModel: ObservableObject {
    // Xcode 13 beta 2 - iOS14 prevents the entry and display of the digit "3" in the TextField
    // that uses it. While in iOS15 it does not (even when the backing store has the correct value).

    private var textFieldTextBacking: String = ""
    var textFieldText: String {
        get {
            textFieldTextBacking
        }

        set {
            /// Prevent infinite loops
            guard newValue != textFieldTextBacking else {
                return
            }

            /// Prevent setting input that contrains a "3" and force a rething of anythig that has speculatively done that,
            guard !newValue.contains("3") else {
                objectWillChange.send()
                return
            }

            textFieldTextBacking = newValue
            objectWillChange.send()
        }
    }
}

struct ContentView: View {
    @StateObject var vm = No3ViewModel()

    var label: String { vm.textFieldText == "" ? "Enter anything except 3"  : "not set"}

    var body: some View {
        return VStack {
            Text("I reject the number 3 - my backing value is = \"\(vm.textFieldText)\"")
            if #available(iOS 15.0, *) {
                TextField(label, text: $vm.textFieldText, prompt: Text("Bum"))
                    .keyboardType(.decimalPad)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            } else {
                // Fallback on earlier versions
                TextField(label, text: $vm.textFieldText)
                    .keyboardType(.decimalPad)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
            }
        }
        .padding([.leading, .trailing], 16)
    }
}

Turns out TextField's handling of the bound variable is also inconsistent with attempts to set values that are not permitted directly and this behaviour is also present in the version of SwiftUI shipping with Xcode 12.5.1 .

Demo of this is below. About 90% certain this is a bug in SwiftUI's TextField and is not really related to Xcode Beta iOS14 or 15 (maybe a bit more visible because it's bust with ObservableObjects as first noticed).

Without this working it's pretty awkward to get controlled value TextField working, so if you're also impacted by this please provide Apple with Feedback as the more they get the more likely they are to fix.

import SwiftUI

/*
 Run TextFieldWeirdnessDemo for a demo of of how TextField's displayed value goes out of alignment with the state
 to which it is bound.

 Problem exists in at least Xcode 12.5.1. 13 Beta 2 & 3 tested with iOS 14.5 & 15.0 on the simulator.

 1) It should not be possible to enter the "3" in the TextField but it is.
 2) Can see @State foo is correctly not being updated but TextField just goes off on its merry way
 and shows it.
 3) If attempt is made to set a "3" in the other control, that does not show it or update.

 Conclusion TextField is broken.
*/


struct ATestCtrl: View {
    @Binding var aBoundVariable: String
    var body: some View {
        VStack {
            Text("Value of aBoundVar = \"\(aBoundVariable)\"")
            Text("\"\(aBoundVariable)\"")
            Button("Set aBoundVar to \"1\"", action: { aBoundVariable = "1" })
            Button("Set aBoundVar to \"2\"", action: { aBoundVariable = "2" })
            Button("Try to set 3 (invalid)", action: { aBoundVariable = "3" })
        }
    }
}




struct TextFieldWeirdnessDemo: View {
    @State var foo: String = ""

    var body: some View {
        let fooBinding = Binding(
            get: {
                foo
            },
            set: { possibleNewFoo in
                guard !possibleNewFoo.contains("3") else {
                    return
                }

                foo = possibleNewFoo

            })

        return
            VStack {
                VStack {
                    Text("TextField ctrl bound to aBoundVar, incorrectly allows entry of \"3\"")
                    TextField("\"\"", text: fooBinding)
                        .keyboardType(.decimalPad)
                        .background(Color.accentColor.opacity(0.2))
                }
                .padding()
                .border(Color.red)
                .padding()

                VStack {
                    Text("ATestCtrl bound to aBoundVar")
                    ATestCtrl(aBoundVariable: fooBinding)
                }
                .padding()
                .border(Color.red)
                .padding()


                Spacer()
                VStack {
                    Text("@State value that's been bound to")
                    Text("\"\(foo)\"")
                }
                .padding()
                .border(Color.green)

                Spacer()
            }
    }
}

struct FeedBackDemo_Previews: PreviewProvider {
    static var previews: some View {
        TextFieldWeirdnessDemo()
    }
}

+1 similar problem: TextField is not correctly displaying the value of the string its bound to

Got same problem with text field only solution but last symbol appears for a moment in in field looking not good IMHO

@Published var  pin:String = "" { didSet {
  DispatchQueue.main.async { [weak self] in
    guard let self = self else { return }
    while self.pin.count > 4 {
      self.pin.removeLast()
    }}}}

For me changing the update on mainQueue did work in iOS 15.

@Published var textFieldText: String = "" {
        didSet {
            if textFieldText.count > 2 {
                DispatchQueue.main.async { [weak self] in
                    guard let self = self else { return }
                    self.textFieldText = String(self.textFieldText.prefix(2))
                }
            }
        }
    }

+1 to this. An approach when you use .onReceive modifier, like in example below, from the view itself also no longer works.

TextField("Total number of people", text: $numOfPeople)
    .keyboardType(.numberPad)
    .onReceive(Just(numOfPeople)) { newValue in
        let filtered = newValue.filter(\.isNumber)
        if filtered != newValue {
            self.numOfPeople = filtered
        }
    }

It says your feedback is not found. Any updates on its status?

The solution with DispatchQueue.main does help, but it crashed the app if I use autocompletion with Apple's keyboard. Works only for regular typing or with 3rd party keyboards.

Expected behaviour iOS14 vs iOS15 - Bound text in TextField goes out of sync with source of truth value on iOS15 Xcode Beta 2
 
 
Q