1. Home
  2. テクノロジー
  3. iOSアプリ「ニフティ不動産」へのSwiftUI導入に向けて、Viewに画面遷移処理が入り込んでしまう課題への解決方針を検討しました

iOSアプリ「ニフティ不動産」へのSwiftUI導入に向けて、Viewに画面遷移処理が入り込んでしまう課題への解決方針を検討しました

はじめに

こんにちは!ニフティライフスタイルでモバイルアプリの開発をしているt-toshasegawです。

過去の記事のiOSアプリ「ニフティ不動産 賃貸版」の画面をSwiftUIで書けるか試してみたでも紹介した通り、モバイルアプリ開発チームではiOSアプリ「ニフティ不動産 賃貸版」へのSwiftUI導入を目指していました。

そこで今回はiOSアプリ「ニフティ不動産 賃貸版」のアーキテクチャに沿ったSwiftUI導入に向けて発生した課題への対応方針をご紹介させて頂きます。

目次

導入にあたっての課題

現在iOSアプリ「ニフティ不動産 賃貸版」ではVIPERアーキテクチャを採用しており、画面遷移処理はViewから独立させRouterで行なっています。

SwiftUIで画面遷移を行うにはNavigationLinkを使用するなどの方法がありますが、これを使用するとViewに画面遷移処理が入り込んでしまうという問題に直面します。

struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: SecondView()) {
                Text("SecondViewへ画面遷移")
            }
        }
    }
}

対応方針

上記の問題を解決するために、画面は従来通りViewControllerを使用しそこにUIHostingControllerを介したSwiftUIで作成したViewを置くという方針にすることにしました。そうすることで画面UIをSwiftUIで実装し、画面遷移処理はこれまで通りRouterに移譲することが出来るようになります。

また、SwiftUIからUIViewController経由でのデータ参照はObservableObjectを使用し、SwiftUIからViewControllerへのイベント受け渡しはdelegateを使用することにしました。

コード例

ViewControllerへSwiftUIで作成したViewを置く

まずはViewControllerにSwiftUIで作成したViewを置くための実装です。

ViewControllerにextensionでaddSwiftUIChildを追加します。そうすることで各ViewControllerからaddSwiftUIChildを呼ぶだけで完結出来るようにしています。

extension UIViewController {
    func addSwiftUIChild<Content: SwiftUI.View>(_ childView: Content) {
        // SwiftUIをUIKitで使用できるように変換
        let hostingVC = UIHostingController(rootView: childView)
        // ViewControllerにSwiftUIを置く
        addChild(hostingVC)
        view.addSubview(hostingVC.view)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false

        // 制約をつける
        NSLayoutConstraint.activate([
            hostingVC.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            hostingVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
        hostingVC.didMove(toParent: self)
    }
}

SwiftUIからViewController経由でデータを参照出来るようにする

次にSwiftUIからViewController経由でデータを参照出来るようにするための実装です。

SwiftUIにDataSourceというObservableObjectを継承させたクラスを作成し、そこに@Publishedを付与したプロパティを持たせます。さらにSwiftUIに@ObservedObjectを付与したdataSourceプロパティ定義することでDataSourceクラスを監視させます。

ViewController側ではpresenterから送られてきた値をそのままdataSource.textsassignすることでデータが更新されます。すると、SwiftUI側で検知されViewが更新されるといった流れになります。

※ViewControllerはpresenterの値をCombineを使用して監視しています

class ViewController: UIViewController {
        var presenter: Presenter!
    private var cancellables: Set<AnyCancellable> = []

    private var dataSource: ContentView.DataSource = .init()

    override func viewDidLoad() {
        super.viewDidLoad()

        addSwiftUIChild(ContentView(dataSource: dataSource))

        // dataSource.textsを更新
        presenter.$texts
            .assign(to: \.dataSource.texts, on: self)
            .store(in: &cancellables)
    }
}

struct ContentView: View {
    // ObservableObjectを継承する
    class DataSource: ObservableObject {
        @Published var texts: [String] = []
    }

    // DataSourceを監視
    @ObservedObject var dataSource: DataSource

    var body: some View {
        // dataSource.textsの変更を検知してViewを更新
        List(dataSource.texts, id: \.self) { text in
            HStack {
                Text(text)
                Spacer()
            }
            .contentShape(Rectangle())
        }
        .listStyle(.plain)
    }
}

SwiftUIからViewControllerにイベントを送る

最後にSwiftUIからViewControllerにタップや長押しなどのイベントを送るための実装です。

新たにContentViewDelegateを作成し、SwiftUI側からdelegateでメソッドを呼びます。ViewControllerをContentViewDelegateに準拠させイベントが送られたらViewController側で処理をすることが出来るようにしています。

class ViewController: UIViewController {
    var presenter: Presenter!
    private var cancellables: Set<AnyCancellable> = []

    private var dataSource: ContentView.DataSource = .init()

    override func viewDidLoad() {
        super.viewDidLoad()

        // delegate: selfを指定しContentViewDelegateに準拠させる
        addSwiftUIChild(ContentView(delegate: self, dataSource: dataSource))
        
        presenter.$texts
            .assign(to: \.dataSource.texts, on: self)
            .store(in: &cancellables)
    }
}

extension ViewController: ContentViewDelegate {
    // タップイベントを検知する
    func didSelect(_ contentView: ContentView, didSelect text: String) {
        // presenterに処理を送る
    }
}

// protocolにdelegateメソッドを作成
protocol ContentViewDelegate: AnyObject {
    func didSelect(
        _ contentView: ContentView,
        didSelect text: String
    )
}

struct ContentView: View {
    class DataSource: ObservableObject {
        @Published var texts: [String] = []
    }

    @ObservedObject var dataSource: DataSource
    weak var delegate: ContentViewDelegate?

    var body: some View {
        List(dataSource.texts, id: \.self) { text in
            HStack {
                Text(text)
                Spacer()
            }
            .contentShape(Rectangle())
            .onTapGesture {
                // delegateでタップイベントを送る
                delegate?.didSelect(self, didSelect: text)
            }
        }
        .listStyle(.plain)
    }
}

さいごに

複雑な実装はせずに画面遷移処理をこれまで通りRouterに移譲することが出来ました。

iOSアプリ「ニフティ不動産 賃貸版」では今後SwiftUIを導入しやすい画面から徐々に置き換えていく計画です。

また今回はViewControllerも従来通り使用するという対応方針にしましたが、今後の展望としてSwiftUIオンリーでの導入検討も行いたいなと思いました。

参考

今回の記事の内容については、下記クックパッド様の記事を参考にさせて頂きました。

この記事をシェア

掲載内容は、記事執筆時点の情報をもとにしています。