SwiftUIアプリをVIPERアーキテクチャーで作り画面遷移処理をまとめる
この記事では画面遷移処理をView実装から引き剥がす方法としてVIPERアーキテクチャを解説し、実際にSwiftUIアプリケーションに適応します。 ログイン画面やNavigation ViewのPush遷移, Tab Bar表示、アラートやモーダル表示など、アプリケーションで利用する画面遷移処理をどう扱うかをみていきましょう。
前回、SwiftUIの画面遷移をCoordinatorパターンでまとめた話にて画面遷移処理をViewから引き剥がすCoordinatorパターンを解説しました。そしてSwiftUIプロジェクトに当てはめました。しかし、画面遷移をUIHostingController
でUIViewController
に変換して実装したために純粋な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の各コンポーネント関係を図にすると次のようになります。
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つの記事が参考になります。
- iOS Project Architecture: Using VIPER | Cheesecake Labs: Cheesecake Labsという開発会社のTech Blogです。VIPERアーキテクチャの詳しい説明とiOSプロジェクトへの適応例が解説されています。ただし、SwiftUIではなくて
UIViewController
でViewの作成が行われています。 - Getting Started with the VIPER Architecture Pattern | raywenderlich.com: SwiftUIとCombineをつかったVIPERアーキテクチャの解説例です。
カエル図鑑
VIPERをつかった画面遷移をまとめる例としてカエル図鑑アプリを作りました。
世界中のカエルを調べられる図鑑アプリです。
このアプリをもとに、SwiftUIアプリで画面遷移をどうハンドリングできるかをみていきます。
VIPERにはModel操作にIteratorとEntityがありますが、カエル図鑑ではデータ操作がそれほどないので実装を省略しています。ご了承ください。
ソースはこちらです。
https://github.com/SatoTakeshiX/SwiftUI-VIPER-Architecture-Pattern
画面構図
カエル図鑑は会員登録が必要なアプリの想定です。
- 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)
}
}
}
}
@EnvironmentObject
のAppState
をプロパティとして保持します。
ZStack内にisLogin
によってViewを出し分けています。
後でRootPresenter/RootRouterを導入したほうがいいかもしれません。画面遷移ロジックがViewに入り込んでいます。
続いてRootView
をSceneDelegate
のscene(_: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としてAppStore
をRootView
に注入しています。
ログイン画面
ログイン画面を実装していきます。ログイン画面は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に変更しています。
AppState
はObservableObject
を準拠しているのでisLogin
が変更されると自動的にそれにアクセスしているRootView
に通知されます。
もう一度RootView
の実装をみてみます。
// RootView
ZStack {
if appState.isLogin {
HomeTabView() // ここが呼ばれる!
} else {
LoginView(appState: self.appState)
}
}
isLogin
がtrueになったのでHomeTabView()
が呼ばれるようになります。
これでログイン前後でアプリ全体の画面切り替えを実装することができました。
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")
]
リスト画面
カエルの情報をリストで表示する画面です。
型名は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)
}
}
}
}
}
ForEach
でpresenter.params.frogs
とプレゼンターから表示データを受け取り、Viewを作ります。
ImageCard
は独自のビューでこの記事では解説を割愛します。実装に興味をある方はこちらのソースを御覧ください。
self.presenter.linkBuilder
はFrogsListPresenter
解説する際にまた説明します。
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
はアバウトページを表示するかどうかのフラグです。モーダル表示をハンドリングします。
リスト画面から詳細画面への実装
リスト画面からカエルのリストをタップして詳細画面へ遷移する実装を解説します。
まずFrogsListPresenter
にlinkBuilder
メソッドを定義します。
func linkBuilder<Content: View>(frog: Frog, @ViewBuilder content: () -> Content) -> some View {
NavigationLink(destination: router.makeDetailView(frog: frog)) {
content()
}
}
引数にfrog
を受け取り、NavigationLink
のdestination
引数に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
でビューをレイアウトしています。
アバウト画面表示(モーダル表示)
リスト画面のナビゲーションアイテムとしてアバウト画面へのリンクボタンを設置します。タップするとモーダル表示でカエル図鑑の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に任せていることがわかります。
FrogsListPresenter
のmakeAboutWebView
メソッドの定義をみてみましょう。
// 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を作って実装していきます。
ここではアラート表示の実装を解説したいと思います。
アラート表示指定
FrogDetailView
でViewに.alert
修飾子をつけます。
List {
...
}
.alert(isPresented: $presenter.isShowError, content: presenter.alertBuilder)
isPresented
引数にPresenterのisShowError
、content
引数も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
}
}
プロパティとして@Published
のisShowError
を保持します。つまり、このプロパティが更新されたら自動的に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)
まとめ
- SwiftUIで画面遷移ロジックをVIPERパターンでViewから分離する例を紹介しました。
- VIPERパターンはMVVMよりも更に単一責任の原則を推し進めたデザインパターン。アプリのコンポーネントをView, Iterator, Presenter, Entity, Routerに分けて実装します。
- カエル図鑑アプリを作り実装を解説しました。
- 各画面ごとにView, Iterator, Presenter, Entity, Routerに分けて実装することで見通しの良いコードになります。
- 個人的な感想ですが、Routerの関数でViewを返すのは面白い実装と思います。自分では気づかなかったですが、柔軟にViewを指定できると気がつきました。
みなさんも画面遷移が複雑になったらVIPERパターンをぜひ検討してください。
参考資料
- Clean Coder Blog
- iOS Project Architecture: Using VIPER | Cheesecake Labs
- Getting Started with the VIPER Architecture Pattern | raywenderlich.com
- Meet VIPER: Mutual Mobile's application of Clean Architecture for iOS apps - Mutual Mobile
- VIPERアーキテクチャ まとめ - Qiita
- iOS Project Architecture : Using VIPER [和訳] - Qiita
- Meet VIPER: Mutual Mobile's application of Clean Architecture for iOS apps - Mutual Mobile
画像
宣伝
インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
MVVMパターンで作成したサンプルアプリやiOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!