SwiftUI と既存の Cocoa ビューに対して共通のデータソースを使い、一方を更新すると他方も更新されるような構成を取る場合、データの受け渡しにトラップがあるため少し注意する必要があります。(以下、Xcode 11準拠)
目標
上半分は SwiftUI で TextField を使ったビュー、下半分は VC 上に UITextField を配置したこれまでのビューです。全体としては SwiftUI にラップされ、アダプターを介して Cocoa のビューを SwiftUI の View として取り込む構造になっています。そして2つのテキストフィールドは一つのデータソースを共有しており、どちらか一方に値を入力するともう一つのフィールドにもそれが反映されるようにしたいとします。
データソースとしては次の定義を使用します。このFieldを @EnvironmentObject として取り込み、各フィールドを name とバインドさせるのが目的です。
final class Field : ObservableObject {
@Published var name: String = ""
}
※なおサンプルは iOS 向けですが、macOS でも型の名前がUI->NSに変わるくらいで基本的なところは同じです。
SwiftUIへのCocoaビューの組み込み
さて、SwiftUI と Cocoa ビューの連携は公式の SwiftUI Tutorials でも最後の方に出てくるのですが、説明が非常におざなりなのでここでもちょっと説明します(SwiftUIの基礎については触れません)
SwiftUI 環境下で Cocoa のビューを使うには、UIViewRepresentable /UIViewControllerRepresentable という特殊な View を使います。UIViewRepresentable はUIView、UIViewControllerRepresentable は VC の生成と更新をラップし、ビューの内容を body として SwiftUI 側に渡すアダプターです。例えば CocoaViewController という VC があった場合、それを取り込んだ CocoaView の基本構成は次のようになります。
struct CocoaView : UIViewControllerRepresentable {
@Binding var name: String // サンプルのテキストフィールドとつなぐための値
func updateUIViewController(_ uiViewController: CocoaViewController, context: Context) {
// ...VCを更新...
}
func makeUIViewController(context: Context) -> CocoaViewController {
let vc = CocoaViewController()
// ...VCの初期化...
return vc
}
}
makeUIViewController は初回のみ呼び出され、以降 SwiftUI 側の更新が入るたびにupdateUIViewController が呼び出されます。SwiftUI の「宣言的」という特性が頭に入っていれば、updateUIViewController のタイミングでステートレスに内容を同期すればよいことがなんとなく分かると思います。そして基本的に View なので、@Binding を保持して SwiftUI 側からのデータフローを受け入れるところもそのままです。
この CocoaView がサンプルの Cocoa 側 VC をラップしたとして、全体の ContentView は以下のようになります。
struct ContentView: View {
@EnvironmentObject var field: Field
// サンプルの画面と対応
var body: some View {
VStack {
Text("SwiftUI:")
TextField("", text: $field.name)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
CocoaView(name: $field.name)
Spacer()
}
}
}
どちらも field.name と値をバインドしており、SwiftUI レベルではシームレスな扱いができていることがわかります。
データ同期
SwiftUI 側に関しては、値さえバインドしておけば後は勝手に @EnvironmentObject と同期してくれるので特に言うことはありません。
一方 Cocoa 側は、上のようにアダプターレベルまでは SwiftUI がよろしくやってくれるので、後はそれを VC とつなぐという作業が必要になります。
これが例えばラベルのようなものであれば、@Binding の値を一方的に VC に流せばよいため、updateUIViewController で直接値を反映してしまうというのもアリです。しかし今回のサンプルのように相互運用を目的とするのであれば、VC 側から @Binding へと逆に値を反映するため仲介役が必要になります。またテーブルのように DataSource や delegate を取るような場合でも同様に仲介役は必要です(理由は後述)
このようなケースに対応するため、UIViewControllerRepresentable は Coordinator という内部型を使用します。
Coordinator の型に特に制限はありませんが、VC 側との連携を目的とする以上、対応する DataSource や delegate に準拠するというのがセオリーです。そして VC の初期化のタイミングで必要なプロパティに設定し、後は VC と同じ寿命で動作すると考えておけば問題ありません。
上のサンプルではこんな感じになります。まずプロトコルを定義するための VC です。後の説明の都合上、値を直接設定せずreloadするという回りくどいやり方をしています。
import UIKit
// SwiftUIと値をやりとりするためのプロトコル
protocol CocoaViewBridge {
var name: String { get set }
}
// Cocoa側のテキストフィールドを制御するVC
class CocoaViewController: UIViewController {
@IBOutlet var textField: UITextField?
var bridge : CocoaViewBridge?
// SwiftUI -> Cocoa 側の変更同期
func reload() {
if bridge != nil {
self.textField?.text = bridge!.name
}
}
// Cocoa -> SwiftUI 側の変更同期
@IBAction func textChanged(_ sender: Any) {
bridge?.name = textField!.text!
}
}
続いてアダプタ側です。
Coordinator を使う場合は、あわせて makeCoordinator をセットで定義します。こうしておくと、updateUIViewController や makeUIViewController で渡される Context 中に Coordinator も含まれるため、必要に応じて参照することができます。
ここでは Coordinator を CocoaViewBridge 準拠とし、VCの初期化のタイミングで渡しています。これにより、VC とのやりとりを Coordinator が仲介して CocoaView とつないでくれる・・・ように見えます。
// Coordinator付き(***問題あり***)
struct CocoaView : UIViewControllerRepresentable {
@Binding var name: String // サンプルのテキストフィールドとつなぐための値
class Coordinator : CocoaViewBridge {
var view: CocoaView // @Bindingとつなぐための参照
// nameのin-outを仲介
var name: String {
get {
self.view.name
}
set(val) {
self.view.name = val
}
}
init(_ view: CocoaView) {
self.view = view
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIViewController(_ uiViewController: CocoaViewController, context: Context) {
uiViewController.reload() // SwiftUI -> Cocoa のデータロード
}
func makeUIViewController(context: Context) -> CocoaViewController {
let vc = CocoaViewController()
vc.bridge = context.coordinator // VC 側でデータソースとして機能
return vc
}
}
ここでようやく冒頭のトラップの話になるのですが、実はこのコードは意図したとおりに動作しません。
Coordinator(self)の罠
上の問題のあるコードでは、Coordinator の生成時に CocoaView が self を渡すという構造になっています。これは公式のチュートリアルでも使われている構造であり、またアダプタと VC を仲介するという目的からいっても一見自然な設定のように思われます。
しかし、冷静に考えると SwiftUI の View は struct であり、Coordinator は class です。つまり、生存期間の長い Coordinator がいつ使い捨てられるか分からない親 View の内容を当てにしているという、いびつな設計になっている事が分かります。
SwiftUI は宣言的という性質上、何からの値の更新があるたびに View を毎回新しく構築し、古いデータを使い捨てます(少なくとも意味論上はそうです)。しかし Cocoa 側の VC はこれまで通り明示的に削除を行わない限り一つのインスタンスが継続します。Coordinator は VC との接続を目的とする以上、その生存期間は VC とセットになっていなければならないわけです。DataSource や delegate が Coordinator を必要とする(もしくはその方が自然な)理由もここにあります。
とはいえ、SwiftUI のデータフローがアダプターまでしか流れてこない以上、それを吸い上げるためのアダプターへの参照は必要であり、そのための self 渡しとなります。
その結果は驚くべきものとなります。例えばサンプルの SwiftUI 側からフィールドが更新されると、それをトリガーとして updateUIViewController が呼び出されます。この時点で @EnvironmentObject も @Binding name も正常に更新されているにも関わらず、VC 側から reload で Coordinator.name を参照すると更新が反映されていないという非常に分かりにくい挙動が起こります。nil を踏んで fatal で落ちるようなこともないため、これに引っかかると原因究明で右往左往するハメになります。
ではどうするのが正しいのかということなのですが、恐らく Coordinator の保持する View は常時最新版に更新するというのが良いのではないかと思います。具体的には updateUIViewController に一行付け加えます。
func updateUIViewController(_ uiViewController: CocoaViewController, context: Context) {
context.coordinator.view = self // 更新前に「今の」Viewを渡す
uiViewController.reload()
}
これならば reload のタイミングでちゃんと生きた View を参照できるため、更新が行方不明になるようなことは起こりません。
まあ正直なところ、struct の self を渡すといういびつさについては解消していないので、将来的にこのままなのか分からない部分ではあります。Apple 公式による Coordinator(self) 自体がまずいのではないかという気もするのですがどうでしょうね。