UIKitのUIをSwiftUIで使う

Swift

はじめに

戻ってまいりました!SwiftUI。

いままでのUIはStoryBoardやxibで組み上げ、ロジック周りをSwiftで書いて連携させる書き方でした。StoryBoardやxibの中身はバイナリではなくXMLなので、Gitでの管理上は問題は無いです。
ですが、XMLを直接いじるわけではなく、XCodeが自動的に裏でXMLを組んでいるので、何かの弾みに中身が書き換わることがあります。こうなってしまうと、Git上では変更ありの状態になってしまい、面倒なことになります。

これに対応するために、UI周りをコードでゴリッゴリ書くという方法もあります。ところが!今度はAutoLayoutの問題が出てきます。数多くの解像度や縦横対応は面倒なものがあります。ボタンやラベルを単純に並べるだけなら良いのですが、そのようなアプリはサンプル的に作るものならいざしらず、実用的なものならある程度複雑になると思います。そうなると力技でのコーディングでは少々苦しくなってくると思います。

そこで、とっても便利なSwiftUIの出番です。ですが!それでもSwiftUIで対応していないものもあります。ヨイショして落とす。
その場合はUIKitのUIをお借りしないといけません。

少々理解しづらかったので、今回はその実験です。
少し長くなりますが、大半はコードなので多分大丈夫です。

段取り

SwiftUIからUIKitのUIを使うには、このような段取りで進めます。

  • UIViewRepresentableに準拠した構造体を作る
  • UIKitのViewを使うためにmakeUIViewメソッドを実装する
  • SwiftUIからUIKitへの連携のためにupdateUIViewメソッドを実装する
  • Coordinatorクラスを作る
  • Coordinatorクラスの生成のためのmakeCoordinatorメソッドを実装する

最後のCoordinator関連の2項目は、お借りするUIKitからSwiftUIへイベントを返す必要が無い場合は不要です。

実演

参考書のご紹介

早速ですが、今回の参考書はこちらです。

この本の12章を参考に進めます。

ただし、本のとおりにUIPageViewUIActivityViewの事例を写経するのも芸がないです。なので、練習として車輪の再発明をします。

  • 入力したテキストに小並感を追加して表示するUILabel
  • テキストに小並感風味を添える処理をするUIButton

を作ります。2つ並行して進めます。

UIViewRepresentableに準拠した構造体を作る

はじめにUIViewRepresentableに準拠した構造体を作ります。

struct PowderOrangeLabel:UIViewRepresentable {
}
struct PowderOrangeButton:UIViewRepresentable {
}

小並感に関係するUIということで、PowderOrangeを付けました。
違和感があるのは気のせいでしょう。

UIKitのViewを使うためにmakeUIViewメソッドを実装する

struct PowderOrangeLabel:UIViewRepresentable {
    func makeUIView(context: Context) -> UILabel {
        let control = UILabel()
        control.numberOfLines = 2
        return control
    }
}

同じようにPowderOrangeButtonにおいてもUIButtonを生成します。

struct PowderOrangeButton:UIViewRepresentable {
    func makeUIView(context: Context) -> UIButton {
        let control = UIButton(frame: CGRect(x: 0, y: 0, width: 150, height: 44))
        control.setTitle("お礼する", for: .normal)
        control.setTitleColor(.orange, for: .normal)
        return control
    }
}

SwiftUIからUIKitへの連携のためにupdateUIViewメソッドを実装する

SwiftUIのプロパティを参照するために@Bindingを付けたプロパティを宣言します。そのプロパティの値が変更されるたびに呼び出されるのがupdateUIViewメソッドです。

ここではプロパティ(String)の値が空でないときに小並感を付加するようにしました。空ならばラベルを空にします。

struct PowderOrangeLabel:UIViewRepresentable {
    @Binding var textString:String
    func makeUIView(context: Context) -> UILabel {
        let control = UILabel()
        control.numberOfLines = 2
        return control
    }
    func updateUIView(_ uiView: UILabel, context: Context) {
        if textString != "" {
            uiView.text = textString + "。\r\nすごいと思いました。(小並感)"
        }
        else {
            uiView.text = textString
        }
    }
}

PowderOrangeButtonについても同様です。
ただし、ここではプロパティの値に応じてUIButtonのラベル文字列を変更したりなどしないため、updateUIViewメソッドの中身は空っぽです。

struct PowderOrangeButton:UIViewRepresentable {
    @Binding var textString:String
    @Binding var resultString:String
    func makeUIView(context: Context) -> UIButton {
        let control = UIButton(frame: CGRect(x: 0, y: 0, width: 150, height: 44))
        control.setTitle("お礼する", for: .normal)
        control.setTitleColor(.orange, for: .normal)
        return control
    }
    func updateUIView(_ uiView: UIButton, context: Context) {
    }
}

Coordinatorを作る

PowderOrangeLabelはSwiftUIからUIKitへの一方通行なので、ここまでで完了です。ここからはPowderOrangeButtonのみで進んでいきます。

新しくCoordinatorクラスを作ります。

このクラスでUIKitのイベントを処理し、UIKitからSwiftUIへの連携を行います。これで双方向の連携が実現できます。

Coordinatorクラス内でやるべきことは、

  • コーディネートする対象(ここでいうところのPowderOrangeButton)を保持する変数の宣言と取得
  • UIKitのイベントで実行する処理の記述

です。

今回は、PowderOrangeButtonを受け取るための変数の宣言と取得、UIButtonがタップされたときのイベントで実行する処理を記述しました。

struct PowderOrangeButton:UIViewRepresentable {
    @Binding var textString:String
    @Binding var resultString:String
    func makeUIView(context: Context) -> UIButton {
        let control = UIButton(frame: CGRect(x: 0, y: 0, width: 150, height: 44))
        control.setTitle("お礼する", for: .normal)
        control.setTitleColor(.orange, for: .normal)
        return control
    }
    func updateUIView(_ uiView: UIButton, context: Context) {
    }
    class Coordinator {
        var control: PowderOrangeButton
        init(_ control: PowderOrangeButton) {
            self.control = control
        }
        @objc func updateInputString(sender:UITextField) {
            if control.textString != "" {
                control.resultString = "「" + control.textString + "」" + " \r\nすごいですかそうですか良かったです。\r\n(粉みかん)";
            }
            else {
                control.resultString = "……。"
            }
        }
    }
}

ちなみに、わかりやすいようにクラス名としてCoordinatorと付けていますが、別にこれでなくてもいいです。
SwiftUIで使うためにUIKitをラップした構造体の中で宣言していて、名前の衝突が起こらないので、わかりやすくストレートなCoordinatorにしています。

ところでこのCoordinatorの仕組み、デザインパターンの一種かと思ったのですがそうでもないのですね。

Coordinatorの生成のためのmakeCoordinatorメソッドを実装する

仕上げに、Coordinatorクラスのインスタンスを生成します。

UIViewRepresentableに準拠した構造体なので、Coordinatorクラスのインスタンス生成にはmakeCoordinatorメソッドを使用します。
これは、makeUIViewメソッドの先頭で一度だけ実行され、context.coordinatorに保持されます。

makeCoordinatorメソッドでCoordinatorクラスのインスタンスを生成した後は、context.coordinatorでクラス内のメソッドを参照できます。

今回はボタンがクリックされたときに処理を実行するため、UIButtonaddTargettouchUpInsideのときに呼び出されるようにしています。
どんな処理が行われるかはご覧の通り、粉みかんを付すだけです。小並感あふれるコメントに対し粉みかんを付したお礼をします。

何言ってるかわからないと思いますが、要はSwiftUI側からUIKitのイベントを拾いリアクションを返してもらう実験をしているだけです。

余談ですが、未だにContextで戸惑うときがあります。「文脈」というぐらいなので、都度都度で役割が微妙に違います。その違いについていけないとトンチンカンなことをしていることがあります。

今もまさにその状況になってやしないかとドキドキしています。

struct PowderOrangeButton:UIViewRepresentable {
    @Binding var textString:String
    @Binding var resultString:String
    func makeUIView(context: Context) -> UIButton {
        let control = UIButton(frame: CGRect(x: 0, y: 0, width: 150, height: 44))
        control.setTitle("お礼する", for: .normal)
        control.setTitleColor(.orange, for: .normal)
        control.addTarget(context.coordinator, action: #selector(Coordinator.updateInputString(sender:)), for: .touchUpInside)
        return control
    }
    func updateUIView(_ uiView: UIButton, context: Context) {
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    class Coordinator {
        var control: PowderOrangeButton
        init(_ control: PowderOrangeButton) {
            self.control = control
        }
        @objc func updateInputString(sender:UITextField) {
            if control.textString != "" {
                control.resultString = "「" + control.textString + "」" + " \r\nすごいですかそうですか良かったです。\r\n(粉みかん)"
            }
            else {
                control.resultString = "……。"
            }
        }
    }
}

使ってみる

さてと、では実際に使ってみます。

平常通り新しいプロジェクトを作り、ContentView.swiftにコードを書きます。サンプル的なコードなので、お粗末な点が少々ありますが、ご容赦ください。

struct ContentView: View {
    @State var inputString:String = ""
    @State var targetString:String = ""
    @State var returnString:String = ""
    var body: some View {
        VStack(alignment: .center, spacing: 8) {
            HStack {
                TextField("何か一言いただけますか?", text: $inputString)
                Button(action: {
                    self.targetString = self.inputString
                }) {Text("答える")}
            }
            PowderOrangeLabel(textString: $targetString)
                .frame(height: 80)
            PowderOrangeButton(textString: $targetString, resultString: $returnString)
                .frame(height: 48)
            Text(self.returnString)
                .frame(height: 80)
            Spacer()
        }
        .padding(12.0)
    }
}

テキストボックスになにか入れて「答える」ボタンをタップします。

コメントを入力し「答える」ボタンをタップします
渡された文字列に小並感が添えられました

小並感あふれるコメントを頂いたので、お礼に小並感をお返しします。「お礼」ボタンをタップします。
「お礼」ボタンはUIButtonで提供されていて、タップイベントとその処理はラップした構造体内で実行しています。

粉みかんにて御礼

今日はここまで

UILabelもUIButtonも、SwiftUIでそれぞれText、Buttonとして用意されているものです。小並感を添えるためだけの機能限定版ですが、それらをあえて自分で作ってみました。極端にニッチな需要は満たせると思います。つまんないと言わないで……

参考書に載っている事例とは比べ物にならないほどショボショボですが、実際に作ってみると流れをつかめるのではないかと思います。こういったことをベースに段階を踏んで参考書を読むと、わかりやすいかもしれません。