Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SwiftUI: How to fix 'AttributeGraph cycle detected through attribute' when calling becomeFirstResponder on UIViewRepresentable UITextField

I'm attempting to set a textfield as first responder using its tag property and a @Binding. As I am unable to access the underlying UITextField from a SwiftUI TextField and call .becomeFirstResponder() directly, I'm having to wrap a UITextField using UIViewRepresentable. The code below works but results in the following console message === AttributeGraph: cycle detected through attribute <#> ===.

It sounds like I have a memory leak and/or retain cycle, I've isolated the issue to the line textField.becomeFirstResponder() but having inspected Xcode's Memory Graph Hierarchy I can not see what is wrong?

Any help provided is be much appreciated.

struct CustomTextField: UIViewRepresentable {
    var tag: Int
    @Binding var selectedTag: Int
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: ResponderTextField

        init(_ textField: ResponderTextField) {
            self.parent = textField
        }

        func textFieldDidChangeSelection(_ textField: UITextField) {
            parent.text = textField.text ?? ""
        }
    }

    func makeUIView(context: Context) -> UITextField {
        let textField = UITextField(frame: .zero)
        textField.tag = tag
        textField.delegate = context.coordinator
        return textField
    }

    func updateUIView(_ textField: UITextField, context: Context) {
        if textField.tag == selectedTag, textField.window != nil, textField.isFirstResponder == false {
            textField.becomeFirstResponder()
        }
    }
}
like image 693
Elliott Avatar asked Jan 12 '20 20:01

Elliott


2 Answers

on a hunch, I put becomeFirstResponder() in an async dispatch. This fixes the warnings. I'm guessing there is some kind of creation loop which happens when the view is created, and you call becomeFirstResponder(), so that calls the view, which triggers SwiftUI to find the view which it hasn't properly created yet (or something like that)

anyway - this works for me:

func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
    if uiView.text != self.text {
        uiView.text = self.text
    }
    if uiView.window != nil, !uiView.isFirstResponder {
        //This triggers attribute cycle if not dispatched
        DispatchQueue.main.async {
            uiView.becomeFirstResponder()
        }
    }

}
like image 76
Confused Vorlon Avatar answered Nov 15 '22 05:11

Confused Vorlon


Confused Vorlon's answer worked for me but it was still a little wonky as it seems that uiView.isFirstResponder was not returning what I was expecting during Navigating to another view. It resulted in uiView.becomeFirstResponder() being called an unexpected amount of time.

Another way to fix this, is to add a boolean in your coordinator to track that you've already made that field first responder.

func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<FocusableTextField>) {
    uiView.text = text
    if isFirstResponder && !context.coordinator.didBecomeFirstResponder {
        uiView.becomeFirstResponder()
        context.coordinator.didBecomeFirstResponder = true
    }
}

class Coordinator: NSObject {

    @Binding var text: String
    var didBecomeFirstResponder: Bool = false

    init(text: Binding<String>) {
        _text = text
    }

    @objc public func textViewDidChange(_ textField: UITextField) {
        self.text = textField.text ?? ""
    }
}
like image 24
streem Avatar answered Nov 15 '22 05:11

streem