From 77a2472dbf0acd258089c73fbbf44cdeb5d422a3 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 14:30:47 +0200 Subject: [PATCH 1/5] Defined the Coordinator target in the Package file. --- Package.swift | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 4304c7e..2b04031 100644 --- a/Package.swift +++ b/Package.swift @@ -2,22 +2,41 @@ import PackageDescription +private var excludePlatforms: [String] = [.PlatformFolder.iOS] + +#if os(iOS) +excludePlatforms = [] +#endif + let package = Package( name: "SwiftLibs", products: [ .library( name: "SwiftLibs", targets: [ + "Coordinator", "Core" ] ), ], dependencies: [], targets: [ + .target( + name: "Coordinator", + dependencies: [], + exclude: excludePlatforms + ), .target( name: "Core", + dependencies: [] + ), + .testTarget( + name: "CoordinatorTests", dependencies: [ - ] + "Coordinator" + ], + path: "Tests/Coordinator", + exclude: excludePlatforms ), .testTarget( name: "CoreTests", @@ -28,3 +47,11 @@ let package = Package( ), ] ) + +// MARK: - String+Constants + +private extension String { + enum PlatformFolder { + static let iOS = "Platform/iOS" + } +} -- 2.47.1 From 3ed652061b9c76847a4bcef03b90a921e051aab4 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 14:39:41 +0200 Subject: [PATCH 2/5] Defined the Coordinator and the Router public protocols. --- .../Coordinator/Protocols/Coordinator.swift | 76 ++++++++++ Sources/Coordinator/Protocols/Router.swift | 61 ++++++++ .../iOS/Helpers/TestCoordinators.swift | 106 ++++++++++++++ .../iOS/Protocols/CoordinatorTests.swift | 137 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 Sources/Coordinator/Protocols/Coordinator.swift create mode 100644 Sources/Coordinator/Protocols/Router.swift create mode 100644 Tests/Coordinator/Platform/iOS/Helpers/TestCoordinators.swift create mode 100644 Tests/Coordinator/Platform/iOS/Protocols/CoordinatorTests.swift diff --git a/Sources/Coordinator/Protocols/Coordinator.swift b/Sources/Coordinator/Protocols/Coordinator.swift new file mode 100644 index 0000000..6fdd227 --- /dev/null +++ b/Sources/Coordinator/Protocols/Coordinator.swift @@ -0,0 +1,76 @@ +// +// Coordinator.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +/// This protocol organize the flow logic between view controllers in the app. +public protocol Coordinator: AnyObject { + + // MARK: Properties + + /// The child coordinators that are being currently presented. + var children: [Coordinator] { get set } + + /// The router that handles how the view controllers in the coordinators will be shown or dismissed. + var router: Router { get } + + // MARK: Functions + + /// Present the coordinator animatedly or not, depending on the given `animated` parameter, and also pass a closure that should be called on dismissal. + /// - Parameters: + /// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not. + /// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed. + func present(animated: Bool, onDismiss: Router.OnDismissedClosure?) + +} + +// MARK: - Coordinator+Implementations + +public extension Coordinator { + + /// Present a child coordinator animatedly or not, depending on the given `animated` parameter, and also pass a closure that should be called on dismissal. + /// - Parameters: + /// - child: A child coordinator to be presented. + /// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not. + /// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed. + func present( + child: Coordinator, + animated: Bool, + onDismiss: Router.OnDismissedClosure? = nil + ) { + store(child) + child.present(animated: animated) { [weak self, weak child] in + guard let self, let child else { + return + } + + self.free(child) + onDismiss?() + } + } + + /// Dismiss the coordinator animatedly or not, depending on the given `animated` parameter. + /// - Parameter animated: A boolean that represents whether the coordinator should be dismissed animatedly or not. + func dismiss(animated: Bool) { + router.dismiss(animated: animated) + } + +} + +// MARK: - Helpers + +private extension Coordinator { + + // MARK: Functions + + func store(_ coordinator: Coordinator) { + children.append(coordinator) + } + + func free(_ coordinator: Coordinator) { + children = children.filter { $0 !== coordinator } + } +} diff --git a/Sources/Coordinator/Protocols/Router.swift b/Sources/Coordinator/Protocols/Router.swift new file mode 100644 index 0000000..48a89ea --- /dev/null +++ b/Sources/Coordinator/Protocols/Router.swift @@ -0,0 +1,61 @@ +// +// Router.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +#endif + +/// This protocol defines how view controllers will be shown and dismissed. +public protocol Router: AnyObject { + + // MARK: Typealiases + + typealias OnDismissedClosure = () -> Void + + // MARK: Functions + + #if canImport(UIKit) + /// Present a view controller animatedly or not, depending on the given `animated` parameter, and also pass a closure that should be called on dismissal. + /// - Parameters: + /// - viewController: A `UIViewController` view controller instance to present. + /// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not. + /// - onDismiss: A closure to be called or executed when the presented view controller is dismissed. + func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissedClosure? + ) + #endif + + /// Dismiss a view controller animatedly or not, depending on the given `animated` parameter. + /// - Parameter animated: A boolean that represents whether the view controller should be dismissed animatedly or not. + func dismiss(animated: Bool) + +} + +#if canImport(UIKit) +// MARK: - Router+Implementations + +public extension Router { + + // MARK: Functions + + /// Present a view controller animatedly or not, depending on the given `animated` parameter. + /// - Parameters: + /// - viewController: A `UIViewController` view controller instance to present. + /// - animated: A boolean that represents whether the view controller should be dismissed animatedly or not. + func present(_ viewController: UIViewController, animated: Bool) { + present( + viewController, + animated: animated, + onDismiss: nil + ) + } + +} +#endif diff --git a/Tests/Coordinator/Platform/iOS/Helpers/TestCoordinators.swift b/Tests/Coordinator/Platform/iOS/Helpers/TestCoordinators.swift new file mode 100644 index 0000000..7b1eb6f --- /dev/null +++ b/Tests/Coordinator/Platform/iOS/Helpers/TestCoordinators.swift @@ -0,0 +1,106 @@ +// +// TestCoordinators.swift +// CoordinatorTests +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Coordinator +import UIKit + +// MARK: - Test coordinators + +class SomeCoordinator: Coordinator { + + // MARK: Properties + + var children: [Coordinator] = [] + var router: Router + + // MARK: Initialisers + + init(router: Router) { + self.router = router + } + + // MARK: Functions + + func present(animated: Bool, onDismiss: (() -> Void)?) { + router.present( + SomeViewController(), + animated: animated, + onDismiss: onDismiss + ) + } + +} + +class SomeOtherCoordinator: Coordinator { + + // MARK: Properties + + var children: [Coordinator] = [] + var router: Router + + // MARK: Initialisers + + init(router: Router) { + self.router = router + } + + // MARK: Functions + + func present(animated: Bool, onDismiss: (() -> Void)?) { + router.present( + SomeOtherViewController(), + animated: animated, + onDismiss: onDismiss + ) + } + +} + +// MARK: - SpyRouter + +class SpyRouter: Router { + + // MARK: Enumerations + + enum State { + case initialised + case presented + case dismissed + } + + // MARK: Properties + + var state: State = .initialised + var viewController: UIViewController? + var animated: Bool? + var onDismiss: OnDismissedClosure? + + // MARK: Functions + + func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissedClosure? + ) { + self.viewController = viewController + self.animated = animated + self.onDismiss = onDismiss + self.state = .presented + } + + func dismiss(animated: Bool) { + self.animated = animated + self.state = .dismissed + } + +} + +// MARK: - Test view controllers + +class SomeViewController: UIViewController {} +class SomeOtherViewController: UIViewController {} diff --git a/Tests/Coordinator/Platform/iOS/Protocols/CoordinatorTests.swift b/Tests/Coordinator/Platform/iOS/Protocols/CoordinatorTests.swift new file mode 100644 index 0000000..9c459e8 --- /dev/null +++ b/Tests/Coordinator/Platform/iOS/Protocols/CoordinatorTests.swift @@ -0,0 +1,137 @@ +// +// CoordinatorTests.swift +// CoordinatorTests +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Coordinator +import UIKit +import XCTest + +final class CoordinatorTests: XCTestCase { + + // MARK: Properties + + private var router: SpyRouter! + private var coordinator: Coordinator! + + // MARK: Tests + + func test_present_withoutOnDismissClosure() { + // Executing this test on the main thread is required as a `UIViewController` instance in being initialised here. + DispatchQueue.main.async { + // GIVEN + self.router = SpyRouter() + self.coordinator = SomeCoordinator(router: self.router) + + + // WHEN + self.coordinator.present(animated: false, onDismiss: nil) + + // THEN + XCTAssertTrue(self.coordinator.children.isEmpty) + XCTAssertEqual(self.router.state, .presented) + XCTAssertTrue(self.router.viewController is SomeViewController) + XCTAssertFalse(self.router.animated ?? true) + XCTAssertNil(self.router.onDismiss) + } + } + + func test_present_withOnDismissClosure() { + // Executing this test on the main thread is required as a `UIViewController` instance in being initialised here. + DispatchQueue.main.async { + // GIVEN + self.router = SpyRouter() + self.coordinator = SomeOtherCoordinator(router: self.router) + + // WHEN + self.coordinator.present(animated: true) {} + + // THEN + XCTAssertTrue(self.coordinator.children.isEmpty) + XCTAssertEqual(self.router.state, .presented) + XCTAssertTrue(self.router.viewController is SomeOtherViewController) + XCTAssertTrue(self.router.animated ?? false) + XCTAssertNotNil(self.router.onDismiss) + } + } + + func test_presentChild_withoutOnDismissClosure() { + // Executing this test on the main thread is required as a `UIViewController` instance in being initialised here. + DispatchQueue.main.async { + // GIVEN + self.router = SpyRouter() + self.coordinator = SomeCoordinator(router: self.router) + + let child = SomeOtherCoordinator(router: self.router) + + // WHEN + self.coordinator.present(child: child, animated: false) + + // THEN + XCTAssertFalse(self.coordinator.children.isEmpty) + XCTAssertEqual(self.coordinator.children.count, 1) + XCTAssertEqual(self.router.state, .presented) + XCTAssertTrue(self.router.viewController is SomeOtherViewController) + XCTAssertFalse(self.router.animated ?? true) + XCTAssertNil(self.router.onDismiss) + } + } + + func test_presentChild_withOnDismissClosure() { + // Executing this test on the main thread is required as a `UIViewController` instance in being initialised here. + DispatchQueue.main.async { + // GIVEN + self.router = SpyRouter() + self.coordinator = SomeOtherCoordinator(router: self.router) + + let child = SomeCoordinator(router: self.router) + + // WHEN + self.coordinator.present(child: child, animated: true) {} + + // THEN + XCTAssertFalse(self.coordinator.children.isEmpty) + XCTAssertEqual(self.coordinator.children.count, 1) + XCTAssertEqual(self.router.state, .presented) + XCTAssertTrue(self.router.viewController is SomeViewController) + XCTAssertTrue(self.router.animated ?? false) + XCTAssertNotNil(self.router.onDismiss) + } + } + + func test_dismiss_notAnimated() { + // GIVEN + router = SpyRouter() + coordinator = SomeOtherCoordinator(router: router) + + // WHEN + coordinator.dismiss(animated: false) + + // THEN + XCTAssertTrue(coordinator.children.isEmpty) + XCTAssertEqual(router.state, .dismissed) + XCTAssertNil(router.viewController) + XCTAssertFalse(router.animated ?? true) + XCTAssertNil(router.onDismiss) + } + + func test_dismiss_animated() { + // GIVEN + router = SpyRouter() + coordinator = SomeOtherCoordinator(router: router) + + // WHEN + coordinator.dismiss(animated: true) + + // THEN + XCTAssertTrue(coordinator.children.isEmpty) + XCTAssertEqual(router.state, .dismissed) + XCTAssertNil(router.viewController) + XCTAssertTrue(router.animated ?? false) + XCTAssertNil(router.onDismiss) + } + +} -- 2.47.1 From a9248a528b3b127899c9699c88757427947022d7 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 14:41:14 +0200 Subject: [PATCH 3/5] Implemented the WindowRouter router for iOS platform. --- .../Platform/iOS/Routers/WindowRouter.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Sources/Coordinator/Platform/iOS/Routers/WindowRouter.swift diff --git a/Sources/Coordinator/Platform/iOS/Routers/WindowRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/WindowRouter.swift new file mode 100644 index 0000000..92a2dd3 --- /dev/null +++ b/Sources/Coordinator/Platform/iOS/Routers/WindowRouter.swift @@ -0,0 +1,43 @@ +// +// WindowRouter.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +/// This class is responsible for populating the window of an application. +public class WindowRouter: Router { + + // MARK: Properties + + /// The window to set manually with a `UIViewController` view controller instance. + private let window: UIWindow? + + // MARK: Initialisers + + /// Initialise this router. + /// - Parameter window: A `UIWindow` window instance to be set manually. + public init(window: UIWindow?) { + self.window = window + } + + // MARK: Functions + + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissedClosure? + ) { + window?.rootViewController = viewController + + window?.makeKeyAndVisible() + } + + public func dismiss(animated: Bool) { + // Nothing to do here... + } + +} -- 2.47.1 From ecf036ec782aa7e6f06f673075dfb5bd21862e84 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 14:42:19 +0200 Subject: [PATCH 4/5] Implemented the PushNavigationRouter and the ModalNavigationRouter routers for iOS platforms, with its respective BaseNavigationRouter base class. --- .../iOS/Routers/BaseNavigationRouter.swift | 68 +++++++++++++++ .../iOS/Routers/ModalNavigationRouter.swift | 85 +++++++++++++++++++ .../iOS/Routers/PushNavigationRouter.swift | 64 ++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 Sources/Coordinator/Platform/iOS/Routers/BaseNavigationRouter.swift create mode 100644 Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift create mode 100644 Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift diff --git a/Sources/Coordinator/Platform/iOS/Routers/BaseNavigationRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/BaseNavigationRouter.swift new file mode 100644 index 0000000..5d6ad45 --- /dev/null +++ b/Sources/Coordinator/Platform/iOS/Routers/BaseNavigationRouter.swift @@ -0,0 +1,68 @@ +// +// BaseNavigationRouter.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +/// This is a base class for the `NavigationRouter` concrete router implementations. +public class BaseNavigationRouter: NSObject { + + // MARK: Properties + + /// A navigation controller to use within this concrete router. + var navigationController: UINavigationController + + /// Dictionary that persist `onDismiss` closure for its respective view controllers until one of the later is dismissed. + var onDismissForViewController: [UIViewController: Router.OnDismissedClosure] = [:] + + // MARK: Initialisers + + /// Initialise this router. + /// - Parameter navigationController: A `UINavigationController` navigation controller instance to use in this router. + init(navigationController: UINavigationController) { + self.navigationController = navigationController + + super.init() + + self.navigationController.delegate = self + } + + // MARK: Functions + + /// Executes the `onDismiss` closure for a given view controller. + /// - Parameter viewController: A `UIViewController` view controller instance for which the on dismiss closure will be executed. + func performOnDismissed(for viewController: UIViewController) { + guard let onDismiss = onDismissForViewController[viewController] else { + return + } + + onDismiss() + + onDismissForViewController[viewController] = nil + } + +} + +// MARK: - UINavigationControllerDelegate + +extension BaseNavigationRouter: UINavigationControllerDelegate { + + // MARK: Functions + + public func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + guard let dismissedViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else { + return + } + + performOnDismissed(for: dismissedViewController) + } + +} diff --git a/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift new file mode 100644 index 0000000..ed7e070 --- /dev/null +++ b/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift @@ -0,0 +1,85 @@ +// +// ModalNavigationRouter.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +public class ModalNavigationRouter: BaseNavigationRouter { + + // MARK: Properties + + /// The parent view controller from where this router is being called from. + public unowned let parentViewController: UIViewController + + // MARK: Initialisers + + /// Initialise this router. + /// - Parameter parentViewController: A `UIViewController` view controller instance from where this router is originated. + public init(parentViewController: UIViewController) { + self.parentViewController = parentViewController + + super.init(navigationController: .init()) + } + +} + +extension ModalNavigationRouter: Router { + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissedClosure? + ) { + onDismissForViewController[viewController] = onDismiss + + if navigationController.viewControllers.isEmpty { + presentModally(viewController, animated: animated) + } else { + navigationController.pushViewController(viewController, animated: animated) + } + } + + public func dismiss(animated: Bool) { + guard let firstViewController = navigationController.viewControllers.first else { + return + } + + performOnDismissed(for: firstViewController) + + parentViewController.dismiss(animated: animated) + } + +} + +// MARK: - Helpers + +private extension ModalNavigationRouter { + + // MARK: Functions + + func presentModally(_ viewController: UIViewController, animated: Bool) { + viewController.navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Cancel", + style: .plain, + target: self, + action: #selector(onCancelPressed) + ) + + navigationController.setViewControllers([viewController], animated: false) + + parentViewController.present(navigationController, animated: animated) + } + + @objc func onCancelPressed() { + guard let firstViewController = navigationController.viewControllers.first else { + return + } + + performOnDismissed(for: firstViewController) + dismiss(animated: true) + } + +} diff --git a/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift new file mode 100644 index 0000000..c152309 --- /dev/null +++ b/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift @@ -0,0 +1,64 @@ +// +// PushNavigationRouter.swift +// Coordinator +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +/// This class is responsible for presenting view controllers, as it is a concrete implementation of the `Router` protocol, but it won't know what view controller or which view controller is next. +public class PushNavigationRouter: BaseNavigationRouter { + + // MARK: Properties + + /// A root view controller coming in from the navigation controller, if any. + private let rootViewController: UIViewController? + + // MARK: Initialisers + + /// Initialise this router. + /// - Parameters: + /// - navigationController: A `UINavigationController` navigation controller instance to use in this router. + /// - rootViewController: A `UIViewController` view controller instance to define as a root view controller of the navigation controller. + /// - Note This initialiser added the `rootViewController` parameter although it is not really needed to differentiate itself from the `.init(navigationController:)` implemented for the `BaseNavigationRouter` base class. + public init( + navigationController: UINavigationController, + rootViewController: UIViewController? = nil + ) { + self.rootViewController = navigationController.viewControllers.first ?? rootViewController + + super.init(navigationController: navigationController) + } + +} + +// MARK: - Router + +extension PushNavigationRouter: Router { + + // MARK: Functions + + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissedClosure? + ) { + onDismissForViewController[viewController] = onDismiss + + navigationController.pushViewController(viewController, animated: animated) + } + + public func dismiss(animated: Bool) { + guard let rootViewController else { + navigationController.popViewController(animated: animated) + return + } + + performOnDismissed(for: rootViewController) + + navigationController.popToViewController(rootViewController, animated: animated) + } + +} -- 2.47.1 From 061ad36a8b89dab1b303ea9a318f365e75928e13 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sun, 16 Apr 2023 16:36:28 +0200 Subject: [PATCH 5/5] Improved some documentation in the ModalNavigationRouter and the PushNavigationRouter routers. --- .../Platform/iOS/Routers/ModalNavigationRouter.swift | 1 + .../Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift index ed7e070..286167c 100644 --- a/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift +++ b/Sources/Coordinator/Platform/iOS/Routers/ModalNavigationRouter.swift @@ -8,6 +8,7 @@ import UIKit +/// This class is responsible for showing view controllers modally, as it is a concrete implementation of the `Router` protocol. public class ModalNavigationRouter: BaseNavigationRouter { // MARK: Properties diff --git a/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift b/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift index c152309..ab2bc6e 100644 --- a/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift +++ b/Sources/Coordinator/Platform/iOS/Routers/PushNavigationRouter.swift @@ -8,7 +8,7 @@ import UIKit -/// This class is responsible for presenting view controllers, as it is a concrete implementation of the `Router` protocol, but it won't know what view controller or which view controller is next. +/// This class is responsible for pushing view controllers into a navigation controller, as it is a concrete implementation of the `Router` protocol. public class PushNavigationRouter: BaseNavigationRouter { // MARK: Properties -- 2.47.1