🤔

MVP+AppRootController+Routerやってみた

2022/03/02に公開

RootControllerとは

UIWindowのrootViewControllerにコンテナとしてViewControllerを配置し,そのViewControllerの子ViewControllerとして各画面を構成すると色々楽になる(らしい)

リポジトリはこちら

で詳しく説明されている.
今回は,RedViewController,BlueViewController,GreenViewControllerの3つのViewControllerを用意し,各ViewControllerから遷移することを目標にした.

VC間の移動はRootViewControllerのcurrentプロパティを差し替え,RootViewControllerの子ViewControllerにすることで行っている.

Routerとは

iOSアプリ設計パターン入門によると,Routerの役割は,遷移先の画面を生成し,遷移処理の責務を担うことである.ここでは,各ViewControllerがRouterを持つのではなく,あるViewControllerの遷移に関するメソッドをプロトコルに切り出し,共通のRouterクラスでそれに準拠させるという実装にした.

通知の流れ

MVPアーキテクチャを使い,ViewがうけたユーザーアクションをPresenterに通知し,PresenterからRouterに通知するという流れにした.

ユーザーがボタンを押す.

ボタンにaddTargetした関数が実行される

@objc func didTapTransitionToBlueButton() {
    guard let presenter = presenter as? RedPresenterProtocol else { fatalError() }
    presenter.didTransitionButtonTapped(to: .blue)
}


presenterのメソッドが呼ばれる

func didTransitionButtonTapped(to color: RedViewController.NextRoute) {
    router.transition(to: color)
}


Routerのメソッドが呼ばれる

func transition(to color: RedViewController.NextRoute) {
    switch color {
    case .red:
        transitionToRed()
    case .blue:
        transitionToBlue()
    case .green:
        transitionToGreen()
    }
}

具体的な実装

PresenterInjectableプロトコル

各ViewControllerはPresenterを持つ.後述するRouterの中でViewControllerを差し替えるための抽象的な関数を作成するために,PresenterInjectableプロトコルを定義し,各ViewControllerに準拠させる.

protocol PresenterInjectable: UIViewController {
    var presenter: ColorPresenter? { get }
    func inject(presenter: ColorPresenter)
}

ViewController

ボタンを定義して配置したり,ボタンが押されたときの通知の設定を行っている.

class RedViewController: UIViewController, PresenterInjectable {
    
    var presenter: ColorPresenter?
    
    func inject(presenter: ColorPresenter) {
        self.presenter = presenter
    }
    
    enum NextRoute {
        case red
        case blue
        case green
    }

    let transitionToRedButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("toRed", for: .normal)
        button.addTarget(self, action: #selector(didTapTransitionToRedButton), for: .touchUpInside)
        return button
    }()
    
    let transitionToBlueButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("toBlue", for: .normal)
        button.addTarget(self, action: #selector(didTapTransitionToBlueButton), for: .touchUpInside)
        return button
    }()
    
    let transitionToGreenButton: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("toGreen", for: .normal)
        button.addTarget(self, action: #selector(didTapTransitionToGreenButton), for: .touchUpInside)
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .red
        setup()
    }

    private func setup() {
        setupTransitionToRedBuuton()
        setupTransitionToBlueButton()
        setupTransitionToGreenButton()
    }
    
    private func setupTransitionToRedBuuton() {
        view.addSubview(transitionToRedButton)
        transitionToRedButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            transitionToRedButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            transitionToRedButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    private func setupTransitionToBlueButton() {
        view.addSubview(transitionToBlueButton)
        transitionToBlueButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            transitionToBlueButton.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            transitionToBlueButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    private func setupTransitionToGreenButton() {
        view.addSubview(transitionToGreenButton)
        transitionToGreenButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            transitionToGreenButton.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            transitionToGreenButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    @objc func didTapTransitionToRedButton() {
        guard let presenter = presenter as? RedPresenterProtocol else { fatalError()
        }
        presenter.didTransitionButtonTapped(to: .red)
    }
    
    @objc func didTapTransitionToBlueButton() {
        guard let presenter = presenter as? RedPresenterProtocol else { fatalError() }
        presenter.didTransitionButtonTapped(to: .blue)
    }
    
    @objc func didTapTransitionToGreenButton() {
        guard let presenter = presenter as? RedPresenterProtocol else { fatalError() }
        presenter.didTransitionButtonTapped(to: .green)
    }
}

ColorPresenterプロトコル

先ほど触れたViewControllerを差し替えるための抽象的な関数の作成には,ViewControllerの抽象化だけでなくPresenterの抽象化を必要となる.ColorPresenterプロトコルを定義し,各Presenterに準拠させる.

protocol ColorPresenter {}
protocol RedPresenterProtocol: ColorPresenter {
    func didTransitionButtonTapped(to color: RedViewController.NextRoute)
}
protocol BluePresenterProtocol: ColorPresenter {
    func didTransitionButtonTapped(to color: BlueViewController.NextRoute)
}

didTransitionButtonTapped()ColorPresenterで定義しようか迷ったが,そのためには引数のViewControllerを抽象化する必要があり,その抽象化にはあまり意味がない(サンプルアプリだからできる抽象化だと感じた)と思ったのでやめた.

Presenterクラス

Viewクラスを弱参照で持たせた.


final class RedPresenter {
    
    private(set) weak var view: PresenterInjectable!
    private let router: RedRouterProtocol
    
    init(view: PresenterInjectable) {
        print("RedPresenter is initialized")
        self.view = view
        self.router = AppDelegate.shared.router
    }
}

extension RedPresenter: RedPresenterProtocol {
    func didTransitionButtonTapped(to color: RedViewController.NextRoute) {
        router.transition(to: color)
    }
}

Routerクラス

古いViewControllerをremoveし,新しいViewControllerを生成しPresenterを設定し,addChildするのはreplace(with viewController: PresenterInjectable, presenter: ColorPresenter)が行っている.この関数を作るためにPresenterInjectableプロトコルとColorPresenterプロトコルを定義した.

class Router {
    
    func transitionToBlue() {
        let blueViewController = BlueViewController()
        let bluePresenter = BluePresenter(view: blueViewController)
        replace(with: blueViewController, presenter: bluePresenter)
    }
    
    func transitionToRed() {
        let redViewController = RedViewController()
        let redPresenter = RedPresenter(view: redViewController)
        replace(with: redViewController, presenter: redPresenter)
    }
    
    func transitionToGreen() {
        let greenViewController = GreenViewController()
        let greenPresenter = GreenPresenter(view: greenViewController)
        replace(with: greenViewController, presenter: greenPresenter)
    }
    
    func replace(with viewController: PresenterInjectable, presenter: ColorPresenter) {
        let rootViewController = AppDelegate.shared.rootViewController
        viewController.inject(presenter: presenter)
    
        rootViewController.current.willMove(toParent: nil)
        rootViewController.current.removeFromParent()
        rootViewController.current.view.removeFromSuperview()
        rootViewController.current = viewController
        
        rootViewController.addChild(rootViewController.current)
        rootViewController.current.view.frame = rootViewController.view.bounds
        rootViewController.view.addSubview(rootViewController.current.view)
        rootViewController.current.didMove(toParent: rootViewController)
    }
}

各RouterProtocolに準拠させている.ViewControllerが列挙型NextRouteを持たせることで,3つの遷移先に対して一つのRouterメソッドで済んでいる.

extension Router: RedRouterProtocol {
    func transition(to color: RedViewController.NextRoute) {
        switch color {
        case .red:
            transitionToRed()
        case .blue:
            transitionToBlue()
        case .green:
            transitionToGreen()
        }
    }
}

Discussion