"Binding<String> action tried to update multiple times per frame" on TextField with CurrentValueSubject binding

Viewed 1700

I've been trying to find a good way to connect a TextField with a viewmodel, so that the inputs to the TextField don't immediately update the view.

Here is a working example:

struct TextFieldSamples: View {
    @StateObject var vm = TFViewModel()

    var body: some View {
        print(Self._printChanges())
        return VStack {
            TextField("placeholder",
                      text: Binding(
                        get: { vm.outputText },
                        set: { vm.subject.value = $0 }
                      ))
            Text(vm.outputText) // this will only update when the textfield holds >5 characters
        }
    }
}

class TFViewModel: ObservableObject {
    @Published var outputText: String = ""
    let subject = CurrentValueSubject<String, Never>("")

    var subscriptions = Set<AnyCancellable>()

    init() {
        subject
            .filter({ $0.count > 5 })
            .sink { self.outputText = $0 }
            .store(in: &subscriptions)
    }
}

However, I came across the propertyWrapped CurrentValueSubject below on the swiftbysundell.com blog

@propertyWrapper
struct Input<Value> {
    var wrappedValue: Value {
        get { subject.value }
        set { subject.send(newValue) }
    }

    var projectedValue: AnyPublisher<Value, Never> {
        subject.eraseToAnyPublisher()
    }

    private let subject: CurrentValueSubject<Value, Never>

    init(wrappedValue: Value) {
        subject = CurrentValueSubject(wrappedValue)
    }
}

Trying to use it, will result in the error Binding<String> action tried to update multiple times per frame.
(Not only that, the input behavior of the TextField gets messed up. This shows especially with Japanese input where the character transformations don't are not working most of the time.)

Here's a example showing this behavior:

struct TextFieldSamples: View {
    @StateObject var vm = TFViewModel()

    var body: some View {
        print(Self._printChanges())
        return VStack {
            TextField("placeholder", text: $vm.inputText)
            Text(vm.outputText)
        }
    }
}

class TFViewModel: ObservableObject {
    @Input var inputText: String = ""
    @Published var outputText: String = ""

    var subscriptions = Set<AnyCancellable>()

    init() {
        $inputText
            .filter({ $0.count > 5 })
            .sink { self.outputText = $0 }
            .store(in: &subscriptions)
    }
}

I don't quite understand why the PropertyWrapper version is not working as expected.

1 Answers

The issue appears to a combination of the binding to the inputText field of vm and the binding to the vm itself as a @StateObject.

When text is entered, It sends the value to the inputText binding. This allows the system to flag that the TextField needs to be re-evaluated.

But inputText is a struct property of the vm object. Because vm is a @StateObject when inputText changes, the vm object broadcasts that the TextFieldSamples view needs to be re-evaluated. As part of that it also tries to set the value of textInput (to the value it already holds) which causes the "binding fired multiple times in the same frame" message.

You can eliminate the extra time the Binding fires by making vm a @State property instead of @StateObject. Now the change to inputText will fire once, and vm will not broadcast the object change message that tries to fire it the second time.

However, when you do that the system won't know TextFieldSamples needs to be reevaluated so it will never see the change to outputText.

Related