【SwiftUI】画面遷移時にカスタムテキストフィールドにFirstResponderを設定する方法

#1. 実現したい画面
+ 複数行の入力ができるテキストフィールドを設置（UIKitのUITextView）
+ テキストフィールドを設置した画面に遷移すると、あるテキストフィールドがフォーカスされ、キーボードが開いている
+ 上記テキストフィールドが複数個設置され、どれにフォーカスするかは任意に予めコードで指定されている

#2. 検証環境

+ Xcode 11.7
+ iOS 13.7
+ Swift 5

#3. コード
+ 呼び出し元（親）：ContentView.swift
+ カスタムビュー（子）：MultiLineTextField.swift


```swift:ContentView.swift
struct ContentView: View {
    var body: some View {
        VStack{
            MultiLineTextField(isFirstResponder: true)  // 画面遷移直後にフォーカスされるview
            Spacer().frame(height: 5)
            MultiLineTextField(isFirstResponder: false)
        }
    }
}
```

以下が、カスタムビューのコードです。本記事の趣旨に沿った部分だけを抜粋しているのでスニペットではありません。

```swift:MultiLineTextField.swift
import SwiftUI

struct MultiLineTextField: UIViewRepresentable {
    var isFirstResponder: Bool // ①このviewを表示した時にFirstResponderを設定するかどうかのフラグ（親のViewで呼び出し時に設定）

    final class Coordinator: NSObject, UITextViewDelegate {
        private var parent: MultiLineTextField
        var didBecomeFirstResponder: Bool = true // ②このViewを呼び出す親Viewの画面を閉じる時にキーボードが表示されてしまうことへの対策

        init(_ textView: MultiLineTextField) {
            self.parent = textView
            super.init()
        }
        
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }
        
        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text
        }
        
        func textViewShouldReturn(_ textView: UITextView) -> Bool {
            textView.resignFirstResponder()
            return true
        }
        
        @objc func closeButton(sender: UITextView) {
            parent.textView.resignFirstResponder()
        }
        
    }
    
    func makeCoordinator() -> Coordinator {
        let coordinator = Coordinator(self)
        return coordinator
    }
    
    func makeUIView(context: UIViewRepresentableContext<MultiLineTextField>) -> UITextView {
        textView.delegate = context.coordinator
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<MultiLineTextField>) {
        uiView.text = text
        if uiView.window != nil, !uiView.isFirstResponder, isFirstResponder, context.coordinator.didBecomeFirstResponder {  // ③
            DispatchQueue.main.async {
               uiView.becomeFirstResponder()
               context.coordinator.didBecomeFirstResponder = false
            }
        }
    }

}
```
　ポイントは、③の_updateUIView_メソッドでのif文です。参考リンクの幾つかの回答を元に実装しました。
　
　_isFirstResponder_フラグを呼び出し元（親）で設定することで、このテキストビュー（_MultiLineTextField()_）を一つの画面に複数配置する場合にも、どれにフォーカスするかを指定できます。

　次に、_coordinator_の_didBecomeFirstResponder_フラグは一見必要なさそうなのですが、必要です。
　このフラグがないと、親画面（_ContentView()_）で何らかの処理を実施して、その画面をdismissする際に、このテキストビューの_updateUIView_メソッドが呼ばれてしまい、dismissのアニメーション中に一瞬キーボードが現れてしまいます（親画面の_onDisappear()_より前のタイミング。理由はよくわかりませんでした。。）。
　_isFirstResponder_フラグは初期値のまま変更されません（structなのでimmutable）。しかし、_becomeFirstResponder()_を意図的に指定する必要があるのは親Viewの起動時だけであり、dismissする時には指定したくないので、新たなフラグとして_didBecomeFirstResponder_を採用しています。_didBecomeFirstResponder_は、最初はtrueで、_updateUIView_メソッドが実行されるとfalseになります（trueに戻ることはありません）。

　_didBecomeFirstResponder_フラグは、_coordinator_に持たせる必要はないですが、ここが一番簡単だと思ったので、ここに置いてます。

（2020/09/08 追記）
　このテキストフィールドを呼び出した際に、「_=== AttributeGraph: cycle detected through attribute 19 ===_」のようなエラーが多数コンソールに出力されました。これは、即座にクラッシュやメモリリークに繋がるものではなさそうですが、どうやら「_becomeFirstResponder()_」の実行タイミングとビュー全体の描画処理の兼ね合いによる不具合が発生しているようです。このため、メインスレッドでの実行を明示し、エラーが解消されました。

#4. 参考にしたリンク
[(stackoverflow) SwiftUI: How to make TextField become first responder?](SwiftUI: How to make TextField become first responder?)
（2020/09/08 追加）
+ [(stackoverflow) SwiftUI: How to fix 'AttributeGraph cycle detected through attribute' when calling becomeFirstResponder on UIViewRepresentable UITextField](https://stackoverflow.com/questions/59707784/swiftui-how-to-fix-attributegraph-cycle-detected-through-attribute-when-calli)
+ [AttributeGraph: cycle detected](https://developer.apple.com/forums/thread/126890)