28. Feb 2023iOS

Jak používá Coordinator Pattern ve Swiftu náš tým pro iOS?

Koordinátor návrhového vzoru není nic nového. Existuje řada návodů, jak ho používat. Dnes vám ukážu, jak ho používáme my.

Andrej JaššoiOs Developer

Proč:

Koordinátor návrhového vzoru pomáhá při rozdělování odpovědností v toku aplikace. Logika navigace tak přechází z view controllera na koordinátora a zároveň tím získáváme větší kontrolu nad navigačním tokem.

V čem jsou naši koordinátoři jiní?

Náš koordinátor je přizpůsobený pro práci s jednosměrným tokem podobným reduxu. Ukážu vám jednoduchý příklad koordinátora, jak ho děláme my, a na konci článku přidám odkaz na knihovnu, kde najdete i GoodCoordinator, který si můžete stáhnout do projektu přes SPM.

Začněme něčím jednoduchým. Nejdřív si vytvoříme soubor enum s názvem Coordinator.swift, který bude obsahovat enum s akcemi, které vypovídají o typu navigace. Měl by obsahovat základní akce, jako je push, present, dismiss, pop, a předvolenou hodnotu none.

import Combine
import UIKit

enum StepAction {

    case push(UIViewController)
    case present(UIViewController, UIModalPresentationStyle)
    case dismiss
    case pop
    case none

}

Následně si vytvoříme generickou třídu Coordinator. Tato třída bude sloužit jako vzor pro ostatní koordinátory, kteří po ní budou dědit. Bude obsahovat navigationController, který se bude vkládat v konstruktoru. Bude optional.

Následně potřebujeme step a cancellables. Jsou součástí knihovny combine dostupné od iOS 13.

Step bude mít property wrapper published, což znamená, že se k němu můžeme subscribenout a naslouchat změnám stejně, jako kdybychom použili didSet closure.

class Coordinator<Step> {

    weak var navigationController: UINavigationController?
    var cancellables: Set<AnyCancellable> = Set()
    @Published var step: Step?

}

Do třídy coordinator přidáme funkci navigate, která bude sloužit jako rozhraní pro child koordinátory, ale bude prázdná a nebude provádět žádnou funkci.

Následně definujeme privátní funkci navigate, která bude provádět standardní navigační akce, které jsme definovali nahoře nad navigation controllerem, který jsme uložili stejně, jako v třídě.

A nakonec definujeme public funkci start, která bude sloužit jako rozhraní child koordinátorů a zároveň bude při každém spuštění naslouchat změně proměnné step, jak už jsem uvedl výše. Step je optional, proto používáme compactMap, aby se zohlednily pouze nenulové hodnoty, a v sink closure voláme naši funkci private navigate.

@discardableResult
    func navigate(to stepper: Step) -> StepAction {
        return .none
    }

    private func navigate(flowAction: StepAction) {
        switch flowAction {
        case .dismiss:
            if let presentedViewController = navigationController?.presentedViewController {
                presentedViewController.dismiss(animated: true, completion: nil)
            } else {
                navigationController?.topViewController?.dismiss(animated: true, completion: nil)
            }

        case .push(let controller):
            navigationController?.pushViewController(controller, animated: true)

        case .pop:
            navigationController?.popViewController(animated: true)

        case .present(let controller, let style):
            controller.modalPresentationStyle = style

            if let presentedViewController = navigationController?.presentedViewController {
                presentedViewController.present(controller, animated: true, completion: nil)
            } else {
                navigationController?.topViewController?.present(
                    controller,
                    animated: true,
                    completion: nil
                )
            }

        case .none:
            break
        }
    }

    @discardableResult
    public func start() -> UIViewController? {
        $step
            .compactMap { $0 }
            .sink { [weak self] in
                guard let self = self else { return }

                self.navigate(flowAction: self.navigate(to: $0))
            }
        .store(in: &cancellables)

        return navigationController
    }

Teď nadefinujme konkrétní navigační akce. Vytvoříme si soubor AppStep.swift a napíšeme 2 jednoduché cases. Později je možné v tomto enume vrstvit strukturu na další koordinátory, přičemž každý case by reprezentoval jeden koordinátor a associated value jeho AppSteps. Teď pojďme definovat ten nejjednodušší případ.

enum AppStep {

    case showFirstViewController
    case showSecondViewController

}

Použijeme ho po úspěšném nastavení parent koordinátoru. Vytvoříme final class AppCoordinator, která bude dědit po Coordinator typu AppStep, který jsme si definovali níže.

V tomto koordinátoru nadefinujeme appwindow. Ve většině praktických případů se logika oken řeší právě tady, protože AppCoordinator obvykle rozhoduje o tom, zda se nacházíme v okně Login, nebo jsme už zalogovaní uvnitř apky. V našem případě použijeme pouze jedno okno, které bude automaticky zvolené od začátku.

Okno nastavíme v initu. A ve funkci start pro něj vytvoříme navigation controller. Okno zobrazíme.

import Foundation
import UIKit
import Combine


// MARK: - Navigation & Initializers

final class AppCoordinator: Coordinator<AppStep> {

    // MARK: - Properties

    private let appWindow: UIWindow

    // MARK: - Init

    override init() {
        appWindow = UIWindow()
        appWindow.frame = UIScreen.main.bounds
    }

    // MARK: - Overrides

    @discardableResult
    override func start() -> UIViewController? {
        super.start()

        let navigationController = UINavigationController()
        self.navigationController = navigationController
        appWindow.rootViewController = navigationController
        appWindow.makeKeyAndVisible()

        return navigationController
    }

}

Teď máme přípravu hotovou a pojďme do navigace něco pushnout. Overrideneme funkci a přes switch nad AppStepem vytvoříme rozhodovací logiku.

V každém AppStep jsme schopní pushnout viewController tak, že vrátíme AppStep s viewControllerem jako parametrem. Taky by se tady dala vrátit hodnota none a do navigation controlleru přidat další navigationController z jiného koordinátoru.

override func navigate(to step: AppStep) -> StepAction {
        switch step {
        case .showFirstViewController(let animated):
            let firstViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["two", "one"])
            return .push(firstViewController)

        case .showSecondViewController(let animated):
            let secondViewController = DocumentBrowserViewController(forOpeningFilesWithContentTypes: ["hone", "two"])
            return .push(secondViewController)

        default:
            return .none
        }

    }

Teď to zkusíme zapojit do apky. Ve třídě Appdelegate vytvoříme proměnnou appCoordinator a vytvoříme ho při zapínání aplikace. Koordinátor následně zavolá start a teď jen stačí nastavit step na potřebný AppStep.

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    private var appCoordinator: AppCoordinator!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        let appCoordinator = AppCoordinator()
        self.appCoordinator = appCoordinator
        appCoordinator.start()
        appCoordinator.step = .showFirstViewController
        appCoordinator.step = .showSecondViewController
        return true
    }

}

Hotovo. Právě jsme použili Coordinator Pattern s použitím combinu. Koordinátor se dá velmi snadno škálovat a díky tomu je vhodný skoro všude.

Pojďme se podívat na situaci při spojení dvou koordinátorů. Vytvoříme si koordinátor AboutAppCoordinator.swift

//
//  AboutAppCoordinator.swift
//  CoordinatorArticle
//
//  Created by Andrej Jasso on 27/09/2021.
//

import UIKit

// MARK: - Steps

final class AboutAppCoordinator: Coordinator<AppStep> {


    // MARK: - Overrides

    override func navigate(to step: AppStep) -> StepAction {
        switch step {
        default:
            return .none
        }
    }

    @discardableResult
    override func start() -> UIViewController? {
        super.start()

        let controller = UIViewController()
        let navigationController = UINavigationController()
        navigationController.viewControllers = [controller]
        controller.view.backgroundColor = .green

        return navigationController
    }
    

}

Tento koordinátor teď prezentujeme přes AppCoordinator step tak, že nahradíme obsah logiky druhého kroku.

case .showSecondViewController(let animated):
	let secondViewController = AboutAppCoordinator().start()
  guard let secondViewController = secondViewController else {
		return .none
	}
	return .present(secondViewController, .automatic)

Hotovo. Proces koordinátorů máme úspěšně za sebou. Pomocí tohoto návodu byste měli být schopni vytvořit funkční navigaci projektu prostřednictvím koordinátorů.

Hotový sample najdete na mém GitHubu.

Knihovnu GoodIOSExtensions i s GoodCoordinatorem najdete na GitHubu GoodRequest.

Andrej JaššoiOs Developer