ViewControllerにSwiftUIで作ったViewを組み込む


はじめに

こんにちは。リブセンスで転職ナビiOSアプリの開発に携わっている須川です。
今回は転職ナビアプリを例にして、ViewControllerにSwiftUIで作ったViewを組み込む方法を紹介します。
また、SwiftUIのPropertyWrappersを使って、表示中のデータに変更があった場合に実装コストをかけずに画面を更新する方法を実現したいと思います。

SwiftUIを組み込む画面について

転職ナビアプリには求人を検索する時の検索条件や検索結果の件数を表示する箇所があるのですが、既存の実装は.xibファイルで作成されています。
今回はxibで作ったViewをSwiftUIで置き換える形でSwiftUIを導入したいと思います。
(画像の赤枠部分が対象のViewです)

Viewの要件

要件は以下の内容です。

  • 右端の青いアイコンをタップすると検索条件の設定画面が開く
  • 検索条件を設定して設定画面を閉じると、新しい検索条件が反映される
    • 検索条件の設定項目
      • 職種(複数選択可)
      • 希望年収
      • 希望勤務地(複数選択可)
  • 求人検索が実行され、検索結果の件数が反映される

SwiftUIで画面作成

ViewModelの作成

まずはViewの要件を満たすViewModelを作成します。
ViewModelはObservableObjectプロトコルに準拠させて、値の更新があった場合に自動的に新しい値がViewに反映されるようにします。
プロパティは@Publishedを付けて宣言することで、値の更新が発生した時にオブザーブしているViewに変更が通知されます。

class SearchConditionHeaderModel: ObservableObject {
    @Published private(set) var workCount = "0"
    @Published private(set) var occupation = "希望職種"
    @Published private(set) var salary = "希望年収"
    @Published private(set) var location = "希望勤務地"

    var buttonAction: (() -> Void)?

    func update(workCount: String, occupation: String, salary: String, location: String) {
        self.workCount = workCount
        self.occupation = occupation
        self.salary = salary
        self.location = location
    }
}

Viewの作成

次にSwiftUIで下記の画面を作成します。
ViewModelのプロパティの更新が通知されるように、@ObservedObjectを付けてViewModelを宣言します。

@ObservedObject var viewModel: SearchConditionHeaderModel


画面右のPreviewはデフォルトだとiPhoneの画面サイズで表示されますが、↓のようにmodifierを追加するとコンテンツのサイズで表示されて余白も追加できます。

struct SearchConditionHeaderView_Previews: PreviewProvider {
    static var previews: some View {
        SearchConditionHeaderView(viewModel: SearchConditionHeaderModel())
            // コンテンツのサイズでpreviewを表示
            .previewLayout(PreviewLayout.sizeThatFits)
            // 余白
            .padding()
    }
}

ViewControllerからSwiftUIを呼び出す

呼び出し側のViewControllerのレイアウトはStoryboardで作成されています。
画像の赤枠部分に先ほどのSwiftUIで作ったViewを表示したいので、既存のxibで作ったViewを削除して、この部分にContainerViewを追加します。
追加したContainerViewIBOutletでViewControllerに紐付けておきます。

次に、最初に作成したViewModelをViewControllerで保持しておきます。

private var searchConditionModel = SearchConditionHeaderModel()

下記の関数をViewControllerのviewDidLoad()で呼び出します。

    private func setupConditionHeaderView() {
        // 1. SwiftUIのViewはUIHostingControllerを継承したクラスにラップ
        //    SwiftUIに↑のViewModelを渡す
        let hostingVC = SearchConditionHeaderHostingViewController(rootView: SearchConditionHeaderView(viewModel: searchConditionModel))
        hostingVC.view.backgroundColor = UIColor(red: 240 / 255, green: 238 / 255, blue: 235 / 255, alpha: 1)

        // 2. hostingVCをchild ViewControllerとしてセット
        addChild(hostingVC)
        // 3. ContainerViewのsubViewにhostingVCのviewを追加
        conditionHeaderView.addSubview(hostingVC.view)
        // 4. 親ViewControllerに子ViewControllerが追加されたことをコール
        hostingVC.didMove(toParent: self)

        // 5. constraintを付けてviewを固定
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
        let bindings = ["view": hostingVC.view]
        conditionHeaderView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|",
                                                      options: NSLayoutConstraint.FormatOptions(rawValue: 0),
                                                      metrics: nil,
                                                      views: bindings as [String: Any]))
        conditionHeaderView.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|",
                                                      options: NSLayoutConstraint.FormatOptions(rawValue: 0),
                                                      metrics: nil,
                                                      views: bindings as [String: Any]))

        // 6. SwiftUIのViewのボタンがタップされた時のアクション
        searchConditionModel.buttonAction = { [weak self] in
            self?.showMatchingCondition()
        }
    }
}

コードについて解説します。

  1. ViewControllerからSwiftUIを呼び出す場合は、UIHostingControllerが必要になります。今回はUIHostingControllerのviewWillAppear()でNavigationControllerの挙動を制御したかったのでサブクラスを使っていますが、特にoverrideしたい箇所がない場合はUIHostingControllerでSwiftUIをラップすればOKです。また、SwiftUIの初期化にviewModelが必要なため、ViewControllerで呼び出したviewModelを渡しています。
  2. SwiftUIをラップしたUIHostingControllerを呼び出し側のViewControllerのchildViewControllerとして追加します。
  3. IBOutletで接続したContainerViewのsubViewにhostingVCのview (SwiftUI) を追加します。
  4. ContainerViewController(この場合はUIHostingControllerのサブクラス)に子ViewControllerを追加した場合は、子ViewControllerに.didMove(toParent:)メソッドで追加の完了を通知する必要があるようです。こちらに詳しい解説がありました。
  5. Viewの制約をつけるか表示するポジションを指定しないと表示がずれてしまうため、今回は制約を付けています。
  6. 最後に、SwiftUIでボタンがタップされた時のアクションを定義します。今回はボタンタップでSwiftUIを保持しているViewControllerから検索条件の設定画面に遷移したいので、ViewControllerで遷移処理を実行します。SwiftUIでボタンがタップされたことをViewControllerに通知する必要があるので、ViewModelのクロージャ経由で通知を行っています。

SwiftUIを表示

アプリを起動するとSwiftUIで実装したViewが表示されます。

値の更新

あとは検索条件が更新されたタイミングでViewModelに定義したupdate()メソッドを実行すれば、検索条件をSwiftUIにバインドできます。

SearchConditionHeaderModel.swift
func update(workCount: String, occupation: String, salary: String, location: String) {
        self.workCount = workCount
        self.occupation = occupation
        self.salary = salary
        self.location = location
    }

Before After

フォントやテキストカラーなどは微妙に変更していますが、xibで作ったViewと同等のものをSwiftUIで置き換えることができました。

Before After
xib SwiftUI

UIKitベースのプロジェクトにSwiftUIを追加した所感

今回は実験的にSwiftUIを追加してみましたが、SwiftUIでViewを作っている時にPreviewの表示に時間が掛かり、レイアウトを確認する作業が大変でした。
Previewを更新する度に差分ビルドが走って、ビルドが完了してから数十秒後に更新が完了するという状態だったので、本格的にSwiftUIを導入する場合はPreview速度の向上を模索する必要がありそうです。
また、状況に応じてどのPropertyWrappersを使えば良いか迷ったので、もう少しPropertyWrappersの理解が必要だと感じました。
思いの外簡単に導入できたので、今後新しい画面を追加するときはSwiftUIを使っていきたいと思います!