[SwiftUI]テスタブルな画面遷移の実装手順

こんにちは、 takeshi-p0601 - Qiita です。普段iOSアプリケーションの開発をメインに業務を行っています。AdventCalender今年2回目の参加です。

本記事ではiOSアプリに関する実装の小技を紹介します。具体的にはSwiftUIでアプリを実装するにあたって、最近試している画面遷移の実装をする際のちょっとした工夫です。内容的にiOS/iPadOSのアプリケーションを開発される方向けの記事になります。

画面遷移の実装

あるA画面から、B画面に遷移するような要件を実装する場合、SwiftUIを使用してどのように実装しますか? UIKit時代のViewControllerと違って、SwiftUIではViewControllerに相当するものが値型となってしまったせいで、UIKitで実装可能だったViewControllerにおける遷移関連の処理を別クラスに委譲させるような、そうした実装はしづらくなりました。

とはいえSwiftUIを使って実装する場合でも、ある程度責務を分けながら実装したいと考えていました。 その中で考えついた実装を紹介します。

まず必要な構成要素は下記三つです。

  • XXXView: 画面
  • XXXViewEventHandler: 画面から来たイベントを受け取るオブジェクト
    • 直近巷で目にふれる、iOSアプリ文脈のアーキテクチャパターンではあまり見ない命名ですが、いわゆるMVPパターンのPresenterやMVVMのViewModel等の責務に近い現場などあるかもしれません。
  • XXXViewRouter: あるViewのアクティベート(ある画面を表示することとします)依頼を受け取ったら、そのViewを作成しつつそのViewを表示させることを、参照元に通知させるオブジェクト

それら構成要素を合わせて、A画面からB画面に遷移するような、処理シーケンスは下記のようなイメージです。

実装方法

※下記の前提があるため、その認識で読み進めてください。

  • わかりやすくするために、あえて命名にアンダースコアをつけている箇所があります
  • 基本的にプッシュ遷移を想定した説明ですが、モーダル遷移でも適用可能です
  • 状態管理の実装をするための知識はある前提で、特に補足せず記載しています

まずRouterを作ります。RouterはViewとEventHandlerどちらからも参照されるものです。

@MainActor
protocol A_ViewRouterable {
    var isB_ViewActivated: Bool { get }
    var activatedB_View: B_View? { get }
    func activateB_View()
}

class A_ViewRouter: ObservableObject, A_ViewRouterable {
    @Published var isB_ViewActivated: Bool = false 
    var activatedB_View: B_View? { self._activatedB_View } 
    private var _activatedB_View: B_View? = nil
    
    func activateB_View() {
      self._activatedB_View = B_View()
      self.isB_ViewActivated = true
    }
}
  • @Published var isB_ViewActivated : View側に変更を通知させるための変数です。あらかじめViewがこの変数の変更を監視します。
  • var activatedB_View: B_View? : アクティベートされた際にView側に参照してもらうための変数です。このRouterクラスではViewの生成も担います
  • func activateB_View() : イベントハンドラーから、アクティベート依頼するためのインターフェースです。今回は特に存在しませんが、例えばB_ViewがA画面からの値を受け取る必要がある場合、このメソッドに引数を持たせるイメージです。

続いてEventHandlerです。

@MainActor
protocol A_EventHandable {
    func tapGoB_ViewButton()
    var a_viewRouter: A_ViewRouterable { get }
}

class A_EventHandler: A_EventHandable {
    let a_viewRouter: A_ViewRouterable
    
    init(a_viewRouter: A_ViewRouterable) {
        self.a_viewRouter = a_viewRouter
    }
    
    func tapGoB_ViewButton() {
        self.a_viewRouter.activateB_View()
    }
}

先ほど定義した、Routerを保持します。そしてtapGoB_ViewButton() が実行された際に、 B_ViewをアクティベートするようにRouterに依頼します。

最後にA_Viewです。

import SwiftUI

struct A_View: View {
    @StateObject var a_viewRouter: A_ViewRouter
    var eventHandler: A_EventHandable
    
    var body: some View {
        VStack {
            Button("Go to B") {
                self.eventHandler.tapGoB_ViewButton()
            }
        }
        .navigationDestination(isPresented: self.$a_viewRouter.isB_ViewActivated,
                               destination: { self.a_viewRouter.activatedB_View })
    }
}

下記の部分でRouterの変数の値の変更を監視しつつ、アクティベートされたタイミングでPush遷移させるようにViewを挿入します。

 .navigationDestination(isPresented: self.$a_viewRouter.isB_ViewActivated,
                                         destination: { self.a_viewRouter.activatedB_View })

構成要素の実装としては、以上で終わりです。

そして最後に重要なことはA画面の生成の実装です。言ってしまうとなんてことありませんが、 ⭐️印で生成したrouterを、A_ViewとEventHandlerがそれぞれ同じ値に依存するように、初期化時にセットしてください。

func activateA_View() {
  let router = A_ViewRouter() // ⭐️
  self._activatedA_View = A_View(a_viewRouter: router, // ここ
                                 eventHandler: A_EventHandler(a_viewRouter: router)) // ここ
  self.isA_ViewActivated = true
}

RouterインスタンスをA_Viewと、EventHandler向けにそれぞれ作成してセットする場合、ViewからEventHandlerをトリガーにしたRouterの値の変更を監視できず、結果画面遷移できない状況が発生します。

効果

EventHandler側は、何をアクティベートさせるかに焦点を当てるような実装をすることが可能で、Router側に画面の生成やアクティベートに必要な変数を管理してもらうことで、ややスッキリしたと思います。

またあくまでロジック上ですが、下記のようにEventHandlerのイベントから画面がアクティベートされているかテストできるようになり、今回の修正を適用しない場合に比べてテストしやすい部分が増えることが期待できます。

import XCTest
@testable import MyApp

final class MyAppTests: XCTestCase {

    @MainActor func test() {
        let router = A_ViewRouter()
        let eventHandler = A_EventHandler(a_viewRouter: router)
        eventHandler.tapGoB_ViewButton()
        XCTAssertNotNil(router.activatedB_View)
        XCTAssertEqual(router.isB_ViewActivated, true)
    }
}

[応用]アラートを表示する実装

iOS/iPadOSにおいて標準で表示できる、アラートについても今回の実装を適用できます。経験上アラートのプログラムは油断しているとView側やEventHandler側に増えがちになるので、必要に応じてRouter側に寄せることも一つ手だと思います。

RouterではAlertItemを生成し、それをアクティベートさせることを担っています。

struct A_ViewAlertItem: Identifiable {
    let id = UUID()
    let type: AlertType

    enum AlertType {
        case networkError(title: String, message: String, okButtonTitle: String)
        case otherError(title: String, message: String, okButtonTitle: String)
    }
}

@MainActor
protocol A_ViewRouterable {
    var isB_ViewActivated: Bool { get }
    var activatedB_View: B_View? { get }
    func activateB_View()
    
    // アラート
    var alertItem: A_ViewAlertItem? { get }
    func activateNetworkErrorAlert()
    func activateOtherErrorAlert(message: String)
}

class A_ViewRouter: ObservableObject, A_ViewRouterable {
    @Published var isB_ViewActivated: Bool = false 
    var activatedB_View: B_View? { self._activatedB_View } 
    private var _activatedB_View: B_View? = nil
    
    @Published var alertItem: A_ViewAlertItem? = nil
    
    func activateB_View() {
      self._activatedB_View = B_View() // 1
      self.isB_ViewActivated = true // 2
    }
    
    func activateNetworkErrorAlert() {
        self.alertItem = A_ViewAlertItem(type:
                .networkError(title: "エラー",
                                  message: "サーバーとの通信に失敗しました。\nやり直してください。",
                                  okButtonTitle: "OK")
        )
    }
    
    func activateOtherErrorAlert(message: String) {
        self.alertItem = A_ViewAlertItem(type:
                .otherError(title: "エラー",
                            message: message,
                            okButtonTitle: "OK")
        )
    }
}

ViewがRouterの変更を監視しつつ、変更があった際にアラートを表示させます。

import SwiftUI

struct A_View: View {
    @StateObject var a_viewRouter: A_ViewRouter
    var eventHandler: A_EventHandable
    
    var body: some View {
        VStack {
            Button("Go to B") {
                self.eventHandler.tapGoB_ViewButton()
            }
        }
        .navigationDestination(isPresented: self.$a_viewRouter.isB_ViewActivated,
                               destination: { self.a_viewRouter.activatedB_View })
    }
    .alert(item: self.$a_ViewRouter.alertItem, content: { alertItem in
        switch alertItem.type {
            case .networkError(let title, let message, let okButtonTitle):
                Alert(title: Text(title),
                      message: Text(message),
                      dismissButton: .default(Text(okButtonTitle)))
            case .otherError(title: let title, message: let message, okButtonTitle: let okButtonTitle):
                Alert(title: Text(title),
                      message: Text(message),
                      dismissButton: .default(Text(okButtonTitle)))
        }
    })
}

SwiftUIのアラートは下記で示されている通り、連続してmodifierを使用して実装することができません。そのためAlertTypeを定義することでその問題を解消させながら、今回の実装を適用してみました。

https://stackoverflow.com/questions/58069516/how-can-i-have-two-alerts-on-one-view-in-swiftui

先ほどのViewの画面遷移の実装と同様に、アラート部分についてもテストしやすさが増すと思いますのでぜひ試してみてください。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 photosynth.co.jp

Akerunにご興味のある方はこちらから akerun.com