LoginSignup
50
37

More than 1 year has passed since last update.

SwiftUI + FlowController パターンの提案

Posted at

コンセプト

concept.png

モチベーション

アプリを SwiftUI ベースで作りたい!
……けど、危険な香りもするので UIKit に逃げられる余地を残しておきたい

SwiftUI

SwiftUI が発表されたのが WWDC2019 なので、今年 2022 年で登場から約 3 年。
ネットを徘徊すると実際のプロダクトへの導入例もだいぶ見かけるようになってきました。

実際に SwiftUI を触ってみると、宣言的 UI によるコード記述量の削減やコンポーネントとしての再利用性の向上、Canvas を利用したプレビューによる開発体験といったあたりは確かに素晴らしいのですが、実際にプロダクトに導入すると個人的には色々と不安な印象です。

特に気になるのが、

  • 画面遷移周りの制限
    • プッシュ遷移での NavigationLink の要求
      • 不可視の NavigationLink を仕込むワークアラウンド
      • NavigationLink が強制的にスタイル変更してくる
    • 複数の sheet modifier を設定する場合に、同階層に設定できない
      • 一応 iOS 14.5 で解消
  • UIKit と比較しての機能不足
    • View の背景色を変えたいのに、 UIKit の appearance でのグローバル設定や Introspect for SwiftUI のようなライブラリ活用が必要
    • UIScrollView の各種 delegate メソッド等の UIKit で公開されているものが未公開なので、細かい調整が効かない

といったあたりで、通常の UIKit 採用アプリでは実現できていたことが、 「SwiftUI を採用しているのでできません」というのは、ステークホルダによっては説明が厳しいところです。

FlowController パターン

話は変わりますが、 iOS アプリのアーキテクチャとして、 FlowController パターンというものが存在します。

オリジナルの提案はおそらく以下の記事。

日本語では、 @shiz さんの以下の紹介記事が詳しいです。

歴史的には、画面遷移に関する処理を VC から外部の Coordinator へと切り出した Coordinator パターンから派生したアーキテクチャで、 Coordinator 自体を UIViewController で実現しています。

厳密には多少異なる気もするのですが、ここでは FlowController パターン自体を 画面レイアウトに責任を持つ実装 とそれを保持する画面遷移に責任を持つ VC (FlowControllerと呼ぶ) で 1 画面を構成するアーキテクチャ という理解で話を進めます。

SwiftUI View + FlowController

  • SwiftUI の利用
  • いざというときの UIKit での対応

を両立させるとなると、基本的な作りは UIKit で、レイアウト実装部分を SwiftUI View に任せるのが良さそうです。

SwiftUI View の UIKit での利用においては、 UIHostingController が提供されています。

image.png

UIHostingControllerUIViewController を継承しているため、都合のいいことに UIHostingController を FlowController として見ると、 画面レイアウトを SwiftUI View で実装した FlowController パターンとして扱えてしまいます。

これを SwiftUI + FlowController パターンという 1 つのアプリアーキテクチャとして見るというのが本記事での提案となります。

大まかなポイントとしては、以下 3 点となります。

  • ベースは UIKit
  • 画面レイアウト実装は、 SwiftUI View + UIHostingController で実現
  • 画面遷移は FlowController パターンで実施

サンプルアプリ

アーキテクチャの概要については上述の通りなのですが、抽象的な議論を避けてもう少し具体的な内容まで掘り下げるために、 SwiftUI + FlowController パターンを採用したサンプルアプリを作成しました。

以降、このサンプルアプリベースで SwiftUI + FlowController パターンについて解説していきます。

リポジトリ

画面構成

アプリ自体は、 Apple 提供の RSS フィードの内容を表示するアプリとなっており、大まかに以下のような画面構成を取ります。

  • ウォークスルー
    • アプリ初回起動時に表示
  • フィード
    • アプリ通常表示の左タブ
    • RSS フィードの内容を表示するメイン機能
  • 設定
    • アプリ通常表示の右タブ
    • 各種設定とテスト機能
対象 SS その 1 SS その 2 SS その 3
ウォークスルー walkthrough_intro.png walkthrough_settings.png walkthrough_finish.png
フィード feed_list_1.png feed_list_3.png app_sales.png
設定 settings_menu.png user_name_setting.png modal_transition_test.png

アーキテクチャ

SwitUI + FlowController パターンにおける View 周りの実装、アーキテクチャについて説明していきます。

基本構成

基本的には、 1 画面に対して以下 3 つの実装が必要となります。

  • FlowController (UIViewController)
    • 画面遷移を担当
    • その他 UIKit ベースでの細かい設定を実施
  • SwiftUI View (View)
    • レイアウトを担当
    • コンテナ VC で実装される親画面の場合は省略
  • ViewModel (ObservableObject)
    • SwiftUI View に対するモデルデータの提供
    • SwiftUI View と FlowController の橋渡し
    • モデルレイヤとの接続

これらの依存性を整理するために、以下の図のような MVVM 型のレイヤ構成を採用しています。

dependency.png

サンプルアプリベースでは MVVM を採用しているのですが、 SwiftUI + FlowController パターンのキモとしては、 SwiftUI ベースの画面レイアウト実装と UIKit ベースの 画面遷移実装を責務分割することにあるため、 MVVM の採用については絶対的な条件ではありません。

Presenter が FlowController の弱参照を保持する MVP や Flux 系のアーキテクチャで SwiftUI Viewのモデルデータを State として管理するといった対応も考えられますが、今回は MVVM をベースとして説明を進めます。

SwiftUI View の実装

SwiftUI View については、 SwiftUI が持つレイアウト実装の記述力の高さを生かすためにも、基本的にはそのまま標準的な SwiftUI 実装を行います。

注意点としては、

  • NavigationLinksheet modifier といった画面遷移に関連する実装は行わない
    • 後述する View のイベント処理に任せる
  • ViewModel については、 @ObservedObject として保持
    • View 外部で初期化されるため、 @StateObject は不要

といったあたりです。

また詳しくは後述するのですが、 FlowController から ViewModel へのアクセスできないと不便なことが多いため、 SwiftUI View では get-only で ViewModel を公開してしまっています。

SwiftUI View での ViewModel の公開
public struct FeedListView: View {
  @ObservedObject private(set) var viewModel: FeedListViewModel
}

public final class FeedListFlowController: UIHostingController<FeedListView> {
  private var _viewModel: FeedListViewModel {
    rootView.viewModel
  }
}

FlowController の実装

FlowController の実装については、コンテナ VC として振る舞う親画面の場合と、通常の画面レイアウトを担う子画面の場合において以下のように実装が異なってきます。

対象画面 継承 SwiftUI View ViewModel
親画面 UIViewController 保持しない 直接保持
子画面 UIHostingController 保持する SwiftUI View経由でアクセス
親画面の実装例
public final class MainFlowController: UIViewController {
  private let _viewModel: MainViewModel
  private let _embeddedTabBarController = UITabBarController()

  public func start() {
    let feed = _feedProvider()
    let settings = _settingsProvider()

    feed.tabBarItem = UITabBarItem(
      title: "Feed",
      image: UIImage(systemName: "doc.text.image"),
      tag: 0
    )
    settings.tabBarItem = UITabBarItem(
      title: "Settings",
      image: UIImage(systemName: "wrench.and.screwdriver"),
      tag: 1
    )

    _embeddedTabBarController.setViewControllers([feed, settings], animated: false)
    _embeddedTabBarController.selectedIndex = 0

    feed.start()
    settings.start()
  }
}
子画面の実装例
public final class WalkthroughIntroFlowController: UIHostingController<WalkthroughIntroView> {
  private var _viewModel: WalkthroughIntroViewModel {
    rootView.viewModel
  }

  override public init(rootView: WalkthroughIntroView) {
    super.init(rootView: rootView)
  }

  public func start() { }

FlowController には、画面遷移後に行う処理をまとめた start() メソッドを実装します。

また、 画面レイアウト実装が SwiftUI では機能不足で UIKit での実装が必要な場合においては、子画面の FlowController においても、 UIViewController を継承した実装として、別途 UIKit ベースの画面実装を行うこととなります。

ViewModel の実装

ViewModel 実装についても SwiftUI View から標準的な利用を考慮して、

  • ObservableObject への適合
  • @Published な プロパティ定義

といった標準的な実装を行います。

ViewModel 実装
public class WalkthroughSettingsViewModel: ObservableObject {
  @Published var feedLanguage: FeedLanguage = .english
  @Published var userName = ""

  var feedLanguageStream: AnyPublisher<FeedLanguage, Never> {
    $feedLanguage.eraseToAnyPublisher()
  }

  var userNameStream: AnyPublisher<String, Never> {
    $userName.eraseToAnyPublisher()
  }

  public init() { }
}

この場合、主にテストに備えた protocol 化が難しくなるという弊害はあるのですが、SwiftUI View でのレイアウト実装の DX のほうが優先されると考えています。

ViewModel に関しては、 SwiftUI View のためのモデルデータ提供に加えて、 SwiftUI View と FlowController 間のデータの橋渡しも必要となってくるため、以下の図のような流れでこれを実現しています。

data_flow.png

ViewModel を protocol 化しない関係上、 SwiftUI View でのプレビュー時や FlowController のユニットテスト作成時のモック化等で問題が発生します。

前者については、 ViewModel の Subclassing である程度対応できるのですが、後者については @Published の projected value ($hoge) を参照している場合、 Property Wrapper については override もできないため、モック化が非常に難しくなります。

この対策として、多少冗長な実装にはなるのですが、 FlowController 側からアクセスする部分については AnyPublisher なインターフェイスを用意しています。

data_flow2.png

View のイベント処理

画面遷移処理を SwiftUI View ではなく FlowController 側で実施するため、ボタンタップ等のイベントを SwiftUI View から FlowController へと伝達する必要があります。

この処理については、 ViewModel を利用して PassthroughSubject or CurrentValueSubject を経由したリアクティブなストリームを構築することで対応しています。

図にすると以下の通り。

event_flow.png

実装的には以下のようになります。

イベント処理実装例
public class ModalTransitionTestViewModel: ObservableObject {
  private let _navigationSubject = PassthroughSubject<Navigation, Never>()
  var navigationSignal: AnyPublisher<Navigation, Never> {
    _navigationSubject.eraseToAnyPublisher()
  }

  func navigate(_ navigation: Navigation) {
    _navigationSubject.send(navigation)
  }
}

public struct ModalTransitionTestView: View {
  @ObservedObject private(set) var viewModel: ModalTransitionTestViewModel

  public var body: some View {
    VStack {
      Button {
        viewModel.navigate(.alert)
      } label: {
        Text("Alert")
      }
      .padding()

      Button {
        viewModel.navigate(.fullScreen)
      } label: {
        Text("Modal .fullScreen")
      }
      .padding()

      Button {
        viewModel.navigate(.pageSheet)
      } label: {
        Text("Modal .pageSheet")
      }
      .padding()
    }
  }
}

public final class ModalTransitionTestFlowController: UIHostingController<ModalTransitionTestView> {
  private var _cancellable = Set<AnyCancellable>()
  private var _viewModel: ModalTransitionTestViewModel {
    rootView.viewModel
  }

  public func start() {
    _cancellable = Set()

    _viewModel.navigationSignal
      .receive(on: DispatchQueue.main)
      .sink { [weak self] navigation in
        guard let self = self else { return }

        switch navigation {
        case .alert:
          self._showNoticeAlertView()

        case .fullScreen:
          self._showFullScreenModalView()

        case .pageSheet:
          self._showPageSheetModalView()
        }
      }
      .store(in: &_cancellable)
  }
}

画面遷移

画面遷移時の動きとしては、オリジナルの FlowController と同様に以下の手順を踏みます。

  1. 遷移先 FlowController のインスタンスを作成
  2. UIKit ベースでの画面遷移処理を実行
    • pushViewController(_:animated:)present(_:animated:completion:)
    • UINavigationController での viewContollers 設定
  3. 遷移先 FlowController の start() メソッド呼び出し
画面遷移処理
public final class FeedFlowController: UIViewController, FeedListFlowControllerDelegate {
  private let _appSalesProvider: () -> AppSalesFlowControllerService
  private let _embeddedNavigationController = UINavigationController()

  public func feedListFlowController(_: FeedListFlowControllerService, didSelect appSales: AppSegment) {
    let sales = _appSalesProvider()

    _embeddedNavigationController.pushViewController(sales, animated: true)

    sales.start(segment: appSales)
  }
}

Coordinator パターンやオリジナル FlowController では、遷移先画面が末端でそこから先の遷移を持たない画面の場合は、レイアウト実装用の VC をそのまま利用しているのですが、 SwiftUI + FlowController パターンにおいては UIHostingController の都合上、必ず FlowController を経由する形としています。

また遷移前後の画面間でデータのやり取りが必要な場合については、 以下の図のように delegate パターンを利用してやりとりをする実装としています。

transition.png

典型的には、UINavigationController を利用した親子画面の構成で子画面側から親画面に画面遷移を要求する場合等になります。

delegate パターンを利用した画面遷移
public final class SettingsFlowController: UIViewController, SettingsMenuFlowControllerDelegate {
  public func start() {
    let menu = _settingsMenuProvider()
    menu.delegate = self

    _embeddedNavigationController.setViewControllers([menu], animated: false)

    menu.start()
  }

  public func settingsMenuFlowController(_: SettingsMenuFlowControllerService, didSelect menuRow: SettingsMenu.Row) {
    switch menuRow {
    case .userNameSetting:
      _showUserNameSettingView()

    default:
      break
    }
  }

  private func _showUserNameSettingView() {
    let userName = _userNameSettingProvider()

    _embeddedNavigationController.pushViewController(userName, animated: true)

    userName.start()
  }
}

public final class SettingsMenuFlowController: UIHostingController<SettingsMenuView> {
  public func start() {
    _cancellable = Set()

    _viewModel.navigationSignal
      .receive(on: DispatchQueue.main)
      .sink { [weak self] navigation in
        guard let self = self else { return }

        switch navigation {
        case let .menu(row):
          self.delegate?.settingsMenuFlowController(self, didSelect: row)
        }
      }
      .store(in: &_cancellable)
  }
}

補足等

SwiftUI での NavigationView 関連の modifier は効くのか

SwiftUI + FlowController パターンにおいては、 NavigationView (UINavigationController) 配下の階層構造が以下のようになります。

親 FlowController (UIViewController)
└── _embeddedNavigationController (UINavigationController)
    └── 子 FlowController (UIHostingController)
        └── View

この場合、 navigationTitle(_:)toolbar(content:) 等が効くのかどうかが気になります。

サンプルアプリで確認した結果は以下の通りで、概ねは問題なさそうです。

サンプルアプリでの確認コード
public struct ToolbarTestView: View {
  @ObservedObject private(set) var viewModel: ToolbarTestViewModel

  public var body: some View {
    List {
      Section("Action") {
        Text(viewModel.actionText)
      }
    }
    .toolbar {
      ToolbarItem(placement: .navigationBarTrailing) {
        Button {
          viewModel.printNavigation1Text()
        } label: {
          Image(systemName: "1.circle")
        }
      }

      ToolbarItem(placement: .navigationBarTrailing) {
        Button {
          viewModel.printNavigation2Text()
        } label: {
          Image(systemName: "2.circle")
        }
      }

      ToolbarItemGroup(placement: .bottomBar) {
        Button("Left") {
          viewModel.printBottomLeftText()
        }
        Spacer()
        Button("Center") {
          viewModel.printBottomCenterText()
        }
        Spacer()
        Button("Right") {
          viewModel.printBottomRightText()
        }
      }
    }
    .navigationBarTitleDisplayMode(.inline)
    .navigationTitle("Toolbar test")
  }

toolbar_test.png

ただ、完全に動作するかと言うと微妙で、サンプルアプリにおいても Large Title 表示の画面からインライン表示の画面へとプッシュ遷移すると、表示がガタつく事象が発生してしまうため、その対策として FlowController 側で navigationItem.largeTitleDisplayMode = .never を設定していたりします。

NavigationLinkのデフォルト表示等

SwiftUI View でのレイアウトにおいて、基本的には NavigationView を利用しない実装となってくるため、通常のSwiftUIでは利用できていた NavigationLink を利用した場合の chevron アイコン付きのセル表示が難しくなっています。

これについては諦めるしかなく、サンプルアプリでも擬似的に同様の表示をするパーツを用意しています。

擬似的な NavigationLink パーツ
/// A Button in NavigationView simulating NavigationLink
///
/// - seealso: https://ideal-reality.com/programing/swiftui-list-navlink-design/
public struct NavigationLinkButton: View {
  private let _title: String?
  private let _action: () -> Void

  public var body: some View {
    Button(action: _action) {
      HStack {
        if let title = _title {
          Text(title)
            .foregroundColor(.primary)
        }
        Spacer()
        Image(systemName: "chevron.right")
          .font(Font.system(size: 14, weight: .semibold))
          .foregroundColor(.secondary)
          .opacity(0.5)
      }
    }
  }

  public init(_ title: String?, action: @escaping () -> Void) {
    self._title = title
    self._action = action
  }
}

こういった副作用についても一部は発生してしまいます。

オリジナルの FlowController パターンとの差異

画面遷移

の項目でも少し触れていますが、オリジナルの FlowController パターンにおいては、画面遷移の末尾となる画面に対しては FlowController を持たずに直接レイアウト実装を扱う VC を参照していたりします。

一方、 SwiftUI + FlowController パターンにおいては基本的に全ての画面において FlowController を作成しています。

このため、本来は画面遷移後の処理を呼び出すための start() メソッドの責務が曖昧になっている部分があります。

オリジナルの FlowController パターンでは存在しない場面でも実装する必要があるため、空の start() メソッドを実装したり、 FlowController のイニシャライザ実行との役割分担等、腹落ちしていない部分があるというのは正直なところです。

最後に

ミニマムな機能ではあるのですが、サンプルアプリレベルのものを SwiftUI + FlowController パターンで実装してみた限りは、プロダクション投入にもまあまあ耐えられそうというのが個人的な感想です。

特に、限定的な利用とはいえ SwiftUI によるレイアウト実装は生産性が高く、特にコンポーネントとしての再利用性やインタラクション実装の面では、 UIKit より明らかな優位性があると感じました。

ベースは UIKit なので、何かあった場合の UIKit によるパワープレイが可能だというのもプロダクション投入においては、一つのアピールポイントになるかと思います。

また、画面レイアウトと画面遷移の機能を責務分割するという方針については、サンプルアプリの実装を通じてなのですが、純粋な SwiftUI の場合においても有効そうだという感触を得ました。

現在の SwiftUI では、どうしても画面遷移部分の密結合が要求されてしまう (主に NavigationLink が原因) のですが、将来的に分離可能となった場合、 SwiftUI + FlowController パターンの FlowController 部分を SwiftUI 化するだけで純 SwiftUI アプリ化できるという、ステップアップも期待できるのでは?と思っています。

以上、 SwiftUI + FlowController パターンの解説+感想でした。

50
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
37