SwiftUIアプリをVIPERアーキテクチャーで作り画面遷移処理をまとめる

この記事では画面遷移処理をView実装から引き剥がす方法としてVIPERアーキテクチャを解説し、実際にSwiftUIアプリケーションに適応します。 ログイン画面やNavigation ViewのPush遷移, Tab Bar表示、アラートやモーダル表示など、アプリケーションで利用する画面遷移処理をどう扱うかをみていきましょう。

SwiftUIアプリをVIPERアーキテクチャーで作り画面遷移処理をまとめる

前回、SwiftUIの画面遷移をCoordinatorパターンでまとめた話にて画面遷移処理をViewから引き剥がすCoordinatorパターンを解説しました。そしてSwiftUIプロジェクトに当てはめました。しかし、画面遷移をUIHostingControllerUIViewControllerに変換して実装したために純粋なSwiftUIのみで実装することができませんでした。

この記事では画面遷移処理をView実装から引き剥がす方法としてVIPERアーキテクチャを解説し、実際にSwiftUIアプリケーションに適応します。
ログイン画面やNavigation ViewのPush遷移, Tab Bar表示、アラートやモーダル表示など、アプリケーションで利用する画面遷移処理をどう扱うかをみていきましょう。

解決したい問題

View実装に画面遷移処理が入り込むことがよくあります。

struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: Text("detail")) { // 画面遷移実装がView実装に入り込む
                Text("main")
            }
        }
    }
}

単純な画面遷移であれば問題ないですが、条件によって画面を出し分けたいとなるとView内ではなくて別のもので管理したくなります。

struct ContentView: View {
    var body: some View {
        NavigationView {
            if flag { // 条件によって画面を出し分け
                NavigationLink(destination: Text("detail")) { // 画面遷移実装がView実装に入り込む
                    Text("main")
                }
            } else {
                Text("flag is off")
            }
        }
    }
}

今回はVIPERアーキテクチャを使ってViewから画面遷移の実装を引き剥がしたいと思います。
VIPERにはRouterという画面遷移を担うコンポーネントがあり、それが役に立つでしょう。

VIPERアーキテクチャとは

VIPERアーキテクチャはMVCやMVVMと同じくアーキテクチャパターンの一つです。
Robert C. Martin氏(通称ボブおじさん)が提唱するClean ArchitectureをiOSアプリケーションで実現するパターンの一つとして知られています。

Clean Architectureについてはこのブログを御覧ください。Clean Coder Blog

ちなみにViperは英語で毒ヘビを意味するそうです。かっこいい!

VIPERは、View, Iterator, Presenter, Entity, Routerの頭文字をとったもので、アプリケーションを単一責任の原則にしたがって構築するアーキテクチャです。

  • View: ユーザーインターフェースを担います。SwiftUIではViewプロトコルが対応します。Presenterから伝えられた情報を表示し、ユーザーの画面操作を処理する責務を負います。
  • Presenter: ViewとIteratorを橋渡しする役目で、Viewへ表示するためのデータを作成します。Viewからのユーザーイベントを受け取り、Iteratorへデータを要求し、そのデータからビューコンテンツを準備し、表示すべき内容をViewに伝えます。
  • Iterator: Entityに関するビジネスロジックが責務です。これはユーザーインターフェースとは独立したものです。例えばバックエンドからAPIで情報を取得するサーバークライアントクラスなどがこれに当てはまります。
  • Entity: アプリケーションのデータモデルです。
  • Router: ナビゲーションロジックを責務とするモジュールです。画面表示のアニメーションやある画面が次にどの画面へ遷移するかを決定します。定義はprotocolで行い、どの画面へ遷移できるかを一覧でわかるようにするとよいです。

VIPERの各コンポーネント関係を図にすると次のようになります。

----------2020-05-31-16.31.30

VIPERの特徴として、画面遷移ロジックをRouterとして切り出しているところです。MVC、MVVMなど他のアーキテクチャでは画面遷移ロジックはViewが担当するので、「画面表示ロジック」と「画面遷移ロジック」2つの責務がViewが扱うことになり煩雑さがありました。Routerを導入することでVIPERはViewの責務を一つ減らすことに成功しています。

MVCとMVVMとの比較

MVCパターンはiOSが登場してから親しまれているアーキテクチャでViewの生成をXibファイルやStoryboardで行い、View ControllerがViewのイベントを操作し、Modelと直接やり取りをするパターンです。ビジネスロジックと描画ロジックをView Controllerが担うのでView Controllerが肥大化しがちな実装パターンになります。
MVVMパターンはViewとModelの間にView Modelというレイヤーを追加し、View Modelにビジネスロジックを実装します。View-ViewModel-Modelのデータのやり取りを一方向にすることで見通しのよい実装を実現できます。
VIPERパターンはMVVMパターンをもっと推し進めた形です。Viewとやり取りするのはPresenterのみ、Modelとやり取りするのはIteratorのみに制限することでPresenterはView表示のみを考えればよく、Iteratorはデータ操作のみを考えれば良くなりました。またRouterを導入することで画面遷移も見通しがよくなっています。

VIPERアーキテクチャの詳しい例

もっとVIPERアーキテクチャについて知りたい場合はこの2つの記事が参考になります。

カエル図鑑

VIPERをつかった画面遷移をまとめる例としてカエル図鑑アプリを作りました。
世界中のカエルを調べられる図鑑アプリです。
このアプリをもとに、SwiftUIアプリで画面遷移をどうハンドリングできるかをみていきます。

VIPERにはModel操作にIteratorEntityがありますが、カエル図鑑ではデータ操作がそれほどないので実装を省略しています。ご了承ください。

ソースはこちらです。
https://github.com/SatoTakeshiX/SwiftUI-VIPER-Architecture-Pattern

画面構図

----------2020-05-31-17.30.20

カエル図鑑は会員登録が必要なアプリの想定です。

  • Login画面: ユーザーがまだログインしていない場合に表示。LoginボタンでタブバーのHome画面へ遷移します。
  • Home画面: リスト画面を設定画面を表示するタブバーです。
  • List画面: カエルをカード型で表示します。タップするとそのカエルの詳細画面に遷移します。
  • Detail画面: 選択されたカエルの詳細画面です。カエルの情報と「Ask The Professor」で専門家に質問できる機能があります。ただしこの機能は今は使えません。アラートで機能が使えない旨をユーザーに伝えます。
  • Setting画面: AboutページとしてアプリのGitHubページをモーダルで表示したり、Logout機能があります。LogoutするとLogin画面へ遷移します。

ソース解説

RootView

まずアプリ全体のEntityとしてAppStateを定義します。
ObservableObjectを準拠することでプロパティ変化を他へ通知できるようにします。

final class AppState: ObservableObject {
    @Published var isLogin = false
}

具体的にはisLoginプロパティでユーザーがログインしているかどうかを保持するクラスです。

続いてアプリが最初に起動する際に表示するRootViewを作ります。ユーザーがログイン状態によって画面を出し分けるビューです。

struct RootView: View {
    @EnvironmentObject var appState: AppState
    var body: some View {
        ZStack {
            if appState.isLogin {
                HomeTabView()
            } else {
                LoginView(appState: self.appState)
            }
        }
    }
}

@EnvironmentObjectAppStateをプロパティとして保持します。
ZStack内にisLoginによってViewを出し分けています。

後でRootPresenter/RootRouterを導入したほうがいいかもしれません。画面遷移ロジックがViewに入り込んでいます。

続いてRootViewSceneDelegatescene(_:willConnectTo:options:)メソッドにアプリのルートビューとして設定します。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    let appState = AppState()
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let rootView = RootView().environmentObject(appState)
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: rootView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

RootView().environmentObject(appState)とすることでenvironmentObjectとしてAppStoreRootViewに注入しています。

ログイン画面

----------2020-05-31-22.51.31

ログイン画面を実装していきます。ログイン画面はRootViewでログインしていない場合に表示する画面です。

// RootView
ZStack {
    if appState.isLogin {
        HomeTabView()
    } else {
        LoginView(appState: self.appState) // ここが呼ばれる!
    }
}

イニシャライザにAppStateを引数に持ち、LoginPresenterを作成して保持します。

struct LoginView: View {
    @ObservedObject var presenter: LoginPresenter
    init(appState: AppState) {
        self.presenter = LoginPresenter(appState: appState)
    }
}

ViewはユーザーのビューイベントをPresenterに通知する必要があります。
LoginViewではログインボタンをタップ際にLoginPresenterへタップイベントを通知します。

var body: some View {
    ScrollView {
        ...
        Button(action: {
            self.presenter.apply(inputs: .didTapLoginButton)
        }, label: {
            ZStack {
                RoundedRectangle(cornerRadius: 10)
                    ...
                Text("Login")
                    ...
            }
        })
    }
}

Buttonのactionクロージャー内でself.presenter.apply(inputs: .didTapLoginButton)とすることでPresenterにビューイベントを渡しています。

続いてLoginPresenterです。

final class LoginPresenter: ObservableObject {
    enum Inputs {
        case didTapLoginButton
    }
    init(appState: AppState) {
        self.appState = appState
    }
    func apply(inputs: Inputs) {
        switch inputs {
            case .didTapLoginButton:
                appState.isLogin = true
        }
    }
    // Private
    private let appState: AppState
}

AppStateを保持し、Viewからのイベントをapplyメソッドでハンドリングします。今回はビューイベントはdidTapLoginButtonのみです。didTapLoginButtonが呼ばれたら単純にappState.isLoginをtrueに変更しています。

AppStateObservableObjectを準拠しているのでisLoginが変更されると自動的にそれにアクセスしているRootViewに通知されます。
もう一度RootViewの実装をみてみます。

// RootView
ZStack {
    if appState.isLogin {
        HomeTabView() // ここが呼ばれる!
    } else {
        LoginView(appState: self.appState) 
    }
}

isLoginがtrueになったのでHomeTabView()が呼ばれるようになります。

これでログイン前後でアプリ全体の画面切り替えを実装することができました。

----------2020-05-31-22.31.13-1

HomeTabView

HomeTabViewはログイン後に表示されるタブバーです。
タブとして表示する画面はリスト画面のFrogsListViewと設定画面のSettingViewの2つです。

struct HomeTabView: View {
    @EnvironmentObject var appState: AppState
    var body: some View {
        TabView {
            NavigationView {
                FrogsListView(presenter: FrogsListPresenter(params: .init(frogs: mockFrogs)))
            }
                .tabItem {
                    VStack {
                        Image(systemName: "house.fill")
                        Text("Frogs Guide")
                    }
            }
           .tag(1)

            NavigationView {
                SettingView(presenter: SettingPresenter(appState: appState))
            }
                .tabItem {
                    VStack {
                        Image(systemName: "gear")
                        Text("Setting")
                    }
            }
            .tag(2)
        }
    }
}

Entity

リスト画面を解説する前にこのアプリで使うEntityを解説します。
FrogというStructを定義します。

struct Frog {
    let id = UUID()
    let imageName: String
    let name: String
    let location: String
}

今回はサンプルということでグローバルなモックモデルを用意しました。

let mockFrogs: [Frog] = [
    Frog(imageName: "blue-poison-dart-frog", name: "blue poison dart frog", location: "Brazil"),
    Frog(imageName: "red-eyed-tree-frog", name: "red eyed tree frog", location: "Mexico"),
    Frog(imageName: "toad", name: "toad", location: "Anywhere")
]

リスト画面

----------2020-05-31-22.53.09

カエルの情報をリストで表示する画面です。
型名はFrogsListViewです。FrogsListPresenterを保持します。

struct FrogsListView: View {
    @ObservedObject var presenter: FrogsListPresenter
    var body: some View {
        List {
            ForEach(presenter.params.frogs) { frog in
                self.presenter.linkBuilder(frog: frog) {
                    ImageCard(imageName: frog.imageName, frogName: frog.name)
                    .frame(height: 240)
                }
            }
        }
    }
}

ForEachpresenter.params.frogsとプレゼンターから表示データを受け取り、Viewを作ります。

ImageCardは独自のビューでこの記事では解説を割愛します。実装に興味をある方はこちらのソースを御覧ください。

https://github.com/SatoTakeshiX/SwiftUI-VIPER-Architecture-Pattern/blob/master/FrogsGuideBook/FrogsGuideBook/ViewComponent/ImageCard.swift

self.presenter.linkBuilderFrogsListPresenter解説する際にまた説明します。

FrogsListPresenterの実装です。

final class FrogsListPresenter: ObservableObject {
    struct Parameter {
        let frogs: [Frog]
    }
    enum Inputs {
        case didTapAboutButton
    }
    private let router = FrogsListRouter()
    let params: Parameter
    @Published var isShowAbout = false
    init(params: Parameter) {
        self.params = params
    }
}

イニシャライザにParameterを必要とします。Parameterはイニシャライザのパラメーターをまとめる型で[Frog]を必要とします。

enumのInputsはユーザーイベントをまとめる型です。
プロパティとしてFrogsListRouterを保持します。isShowAboutはアバウトページを表示するかどうかのフラグです。モーダル表示をハンドリングします。

リスト画面から詳細画面への実装

リスト画面からカエルのリストをタップして詳細画面へ遷移する実装を解説します。
まずFrogsListPresenterlinkBuilderメソッドを定義します。

func linkBuilder<Content: View>(frog: Frog, @ViewBuilder content: () -> Content) -> some View {
    NavigationLink(destination: router.makeDetailView(frog: frog)) {
        content()
    }
}

引数にfrogを受け取り、NavigationLinkdestination引数にrouter.makeDetailViewでfrogを渡します。遷移後の画面をRouterに任せているのです。
第二引数のcontentクロージャーは呼び出し側のViewでViewレイアウトができるようにするためのものです。そのためNavigationLinkの第二引数と同じ型にしています。

呼び出し側のFrogsListViewの実装に戻ります。

ForEach(presenter.params.frogs, id: \.id) { frog in
    self.presenter.linkBuilder(frog: frog) {
        ImageCard(imageName: frog.imageName, frogName: frog.name)
        .frame(height: 240)
    }
}

ForEach内にself.presenter.linkBuilderを指定することでNavigationLinkを作っています。第二引数のクロージャーがあることでFrogsListView内でViewのレイアウトができます。ここではImageCardでビューをレイアウトしています。

アバウト画面表示(モーダル表示)

----------2020-05-31-23.47.59

リスト画面のナビゲーションアイテムとしてアバウト画面へのリンクボタンを設置します。タップするとモーダル表示でカエル図鑑のGitHubリポジトリページをWebビューで表示します。
これの実装を解説します。

FrogsListViewをみてみましょう。

var body: some View {
    List {
        ...
    }
    .navigationBarItems(trailing: presenter.makeAboutButton())
}

まずナビゲーションバーアイテムを設定します。
View修飾子の.navigationBarItemsにプレゼンターのmakeAboutButtonメソッドを使ってボタンを作成します。

func makeAboutButton() -> some View {
    Button(action: goToAbout) {
    Image(systemName: "questionmark.circle")
    }
}

ボタンをタップした後のactionクロージャーはgoToAboutメソッドで定義しています。

@Published var isShowAbout = false
private func goToAbout() {
    isShowAbout = true
}

isShowAboutフラグをtrueにしています。@Publishedなので値が変わるとそれにアクセスしているViewが自動的に更新されます。

FrogsListViewに戻って、.sheet修飾子でisShowAboutでモーダルを表示する実装を追加します。

List {
    ...
}
.sheet(isPresented: $presenter.isShowAbout) {
    self.presenter.makeAboutWebView()
}

self.presenter.makeAboutWebView()でモーダル表示のViewはPresenterに任せていることがわかります。

FrogsListPresentermakeAboutWebViewメソッドの定義をみてみましょう。

// FrogsListPresenter
func makeAboutWebView() -> some View {
    let url = URL(string: "https://github.com/SatoTakeshiX/SwiftUI-VIPER-Architecture-Pattern")!
    let webView = WebView(url: url)
    return webView
}

WebViewはSafariViewControllerをSwiftUIで利用できるようにしたコンポーネントです。
GitHubリポジトリのLinkを指定してWebページを作成しています。

詳細画面

詳細画面もリスト画面と同じく、ViewとPresenterを作って実装していきます。
ここではアラート表示の実装を解説したいと思います。

アラート表示指定

----------2020-06-01-0.19.10

FrogDetailViewでViewに.alert修飾子をつけます。

List {
    ...
}
.alert(isPresented: $presenter.isShowError, content: presenter.alertBuilder)

isPresented引数にPresenterのisShowErrorcontent引数もPresenterのalertBuilderメソッドでAlertのビューを作るようにします。

FrogDetailPresenterの解説です。

final class FrogDetailPresenter: ObservableObject {
    enum Inputs {
        case didTapAskTheProfessor
    }
    @Published var isShowError = false
    let frog: Frog
    init(frog: Frog) {
        self.frog = frog
    }
}

プロパティとして@PublishedisShowErrorを保持します。つまり、このプロパティが更新されたら自動的にFrogDetailView.alert修飾子が動作します。

続いてAlertを作るalertBuilderメソッドです。

func alertBuilder() -> Alert {
    let alertButton = Alert.Button.default(Text("OK")) {
        print("did tap alert OK button")
    }
    let alert = Alert(title: Text("This feature is out of service"), message: Text("Please wait a little longer for service to begin. It may take a few days."), dismissButton: alertButton)
    return alert
}

alertButtonでアラートのボタンを作り、タイトルとメッセージのあるアラートを作って返しています。

ユーザータップでアラート表示

さて、アラートを表示するにはユーザーイベントが必要です。今回は「Ask the Professor」というセルをタップしたらアラートが表示されるように実装します。
FrogDetailViewのListでセルを作ります。

List {
    ...
    Section(header: Text("Feature")) {
        self.presenter.makeAskTheProfessorButton()
    }
}

セルのビューはPresenterのmakeAskTheProfessorButtonメソッドを呼び出すことで作成します。
FrogDetailPresenterに戻りmakeAskTheProfessorButtonメソッドの定義をみてみましょう。

func makeAskTheProfessorButton() -> some View {
    Button(action: didTapAskButton) {
        HStack {
            Text("Ask the Professor")
            Spacer()
            Image(systemName: "chevron.right")
                .renderingMode(.template)
            .resizable()
            .aspectRatio(contentMode: .fit)
                .foregroundColor(.gray)
            .frame(width: 20, height: 20)
        }
    }
}

Buttonを作成しています。actionクロージャーにdidTapAskButtonメソッドを指定しています。

func makeAskTheProfessorButton<Content: View>(@ViewBuilder content: () -> Content) -> some View と定義してButtonのレイアウトをView側に任せるとよりよい実装になるでしょう。

didTapAskButtonメソッドではisShowErrorをtrueにします。

private func didTapAskButton() {
    isShowError = true
}

isShowError@Publishedつきのプロパティなので変更があれば自動的に通知されます。

FrogDetailView.alert修飾子が起動しアラートビューが表示されます。

List {
    ...
}
.alert(isPresented: $presenter.isShowError, content: presenter.alertBuilder)

----------2020-06-01-0.55.54

まとめ

  • SwiftUIで画面遷移ロジックをVIPERパターンでViewから分離する例を紹介しました。
  • VIPERパターンはMVVMよりも更に単一責任の原則を推し進めたデザインパターン。アプリのコンポーネントをView, Iterator, Presenter, Entity, Routerに分けて実装します。
  • カエル図鑑アプリを作り実装を解説しました。
  • 各画面ごとにView, Iterator, Presenter, Entity, Routerに分けて実装することで見通しの良いコードになります。
  • 個人的な感想ですが、Routerの関数でViewを返すのは面白い実装と思います。自分では気づかなかったですが、柔軟にViewを指定できると気がつきました。

みなさんも画面遷移が複雑になったらVIPERパターンをぜひ検討してください。

参考資料

画像

宣伝

インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
MVVMパターンで作成したサンプルアプリやiOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!




https://nextpublishing.jp/book/12491.html