diff --git a/Libraries/Coordination/Kit/Sources/Protocols/Coordinator.swift b/Libraries/Coordination/Kit/Sources/Protocols/Coordinator.swift new file mode 100644 index 0000000..61607d0 --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Protocols/Coordinator.swift @@ -0,0 +1,57 @@ +// +// Coordinator.swift +// ReviewsCoordinationKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +public protocol Coordinator: AnyObject { + + // MARK: Properties + var children: [Coordinator] { get set } + var router: Router { get } + + // MARK: Functions + func present(animated: Bool, onDismiss: Router.OnDismissClosure?) + +} + +// MARK: - Implementations +public extension Coordinator { + + // MARK: Functions + func present( + child: Coordinator, + animated: Bool, + onDismiss: Router.OnDismissClosure? = nil + ) { + store(child) + + child.present(animated: animated) { [weak self, weak child] in + guard let self, let child else { return } + + self.free(child) + onDismiss?() + } + } + + 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/Libraries/Coordination/Kit/Sources/Protocols/Router.swift b/Libraries/Coordination/Kit/Sources/Protocols/Router.swift new file mode 100644 index 0000000..4a256a9 --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Protocols/Router.swift @@ -0,0 +1,42 @@ +// +// Router.swift +// ReviewsCoordinationKet +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import UIKit + +public protocol Router: AnyObject { + + // MARK: Type aliases + typealias OnDismissClosure = () -> Void + + // MARK: Functions + func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissClosure? + ) + + func dismiss(animated: Bool) + +} + +// MARK: - Implementations +public extension Router { + + // MARK: Functions + func present( + _ viewController: UIViewController, + animated: Bool + ) { + present( + viewController, + animated: animated, + onDismiss: nil + ) + } + +} diff --git a/Libraries/Coordination/Kit/Sources/Routers/NavigationRouter.swift b/Libraries/Coordination/Kit/Sources/Routers/NavigationRouter.swift new file mode 100644 index 0000000..ebccc00 --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Routers/NavigationRouter.swift @@ -0,0 +1,55 @@ +// +// NavigationRouter.swift +// ReviewsCoordinationKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import UIKit + +public class NavigationRouter: NSObject { + + // MARK: Properties + var navigationController: UINavigationController + var onDismissForViewController: [UIViewController: Router.OnDismissClosure] = [:] + + // MARK: Initialisers + init(navigationController: UINavigationController) { + self.navigationController = navigationController + + super.init() + + self.navigationController.delegate = self + } + + // MARK: Functions + func performOnDismiss(for viewController: UIViewController) { + guard let onDismiss = onDismissForViewController[viewController] else { + return + } + + onDismiss() + + onDismissForViewController[viewController] = nil + } + +} + +// MARK: - UINavigationControllerDelegate +extension NavigationRouter: UINavigationControllerDelegate { + + // MARK: Functions + public func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + guard let viewControllerToDismiss = navigationController.transitionCoordinator?.viewController(forKey: .from) else { + return + } + + performOnDismiss(for: viewControllerToDismiss) + } + +} diff --git a/Libraries/Coordination/Kit/Sources/Routers/PushRouter.swift b/Libraries/Coordination/Kit/Sources/Routers/PushRouter.swift new file mode 100644 index 0000000..78ace46 --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Routers/PushRouter.swift @@ -0,0 +1,56 @@ +// +// File.swift +// ReviewsCoordinationKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import UIKit + +public class PushRouter: NavigationRouter { + + // MARK: Constants + private let rootViewController: UIViewController? + + // MARK: Initialisers + public init( + navigationController: UINavigationController, + rootViewController: UIViewController? = nil + ) { + self.rootViewController = navigationController.viewControllers.first ?? rootViewController + + super.init(navigationController: navigationController) + } + +} + +// MARK: - Router +extension PushRouter: Router { + + // MARK: Functions + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissClosure? + ) { + onDismissForViewController[viewController] = onDismiss + + navigationController.pushViewController(viewController, animated: animated) + } + + public func dismiss(animated: Bool) { + guard let rootViewController else { + navigationController.popViewController(animated: animated) + return + } + + performOnDismiss(for: rootViewController) + + navigationController.popToViewController( + rootViewController, + animated: animated + ) + } + +} diff --git a/Libraries/Coordination/Kit/Sources/Routers/WindowRouter.swift b/Libraries/Coordination/Kit/Sources/Routers/WindowRouter.swift new file mode 100644 index 0000000..21c7194 --- /dev/null +++ b/Libraries/Coordination/Kit/Sources/Routers/WindowRouter.swift @@ -0,0 +1,35 @@ +// +// WindowRouter.swift +// ReviewsCoordinationKit +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import UIKit + +public class WindowRouter: Router { + + // MARK: Constants + private let window: UIWindow? + + // MARK: Initialisers + public init(window: UIWindow?) { + self.window = window + } + + // MARK: Functions + public func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissClosure? + ) { + window?.rootViewController = viewController + window?.makeKeyAndVisible() + } + + public func dismiss(animated: Bool) { + // Nothing to do here... + } + +} diff --git a/Libraries/Coordination/Test/Helpers/Coordinators/OtherCoordinator.swift b/Libraries/Coordination/Test/Helpers/Coordinators/OtherCoordinator.swift new file mode 100644 index 0000000..7cfcc4c --- /dev/null +++ b/Libraries/Coordination/Test/Helpers/Coordinators/OtherCoordinator.swift @@ -0,0 +1,36 @@ +// +// OtherCoordinator.swift +// ReviewsCoordinationTest +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsCoordinationKit + +final class OtherCoordinator: Coordinator { + + // MARK: Constants + let router: Router + + // MARK: Properties + var children: [Coordinator] = [] + + // MARK: Initialisers + init(router: Router) { + self.router = router + } + + // MARK: Functions + func present( + animated: Bool, + onDismiss: (() -> Void)? + ) { + router.present( + OtherViewController(), + animated: animated, + onDismiss: onDismiss + ) + } + +} diff --git a/Libraries/Coordination/Test/Helpers/Coordinators/SomeCoordinator.swift b/Libraries/Coordination/Test/Helpers/Coordinators/SomeCoordinator.swift new file mode 100644 index 0000000..aafe216 --- /dev/null +++ b/Libraries/Coordination/Test/Helpers/Coordinators/SomeCoordinator.swift @@ -0,0 +1,36 @@ +// +// SomeCoordinator.swift +// ReviewsCoordinationTest +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsCoordinationKit + +final class SomeCoordinator: Coordinator { + + // MARK: Constants + let router: Router + + // MARK: Properties + var children: [Coordinator] = [] + + // MARK: Initialisers + init(router: Router) { + self.router = router + } + + // MARK: Functions + func present( + animated: Bool, + onDismiss: (() -> Void)? + ) { + router.present( + SomeViewController(), + animated: animated, + onDismiss: onDismiss + ) + } + +} diff --git a/Libraries/Coordination/Test/Helpers/Routers/SpyRouter.swift b/Libraries/Coordination/Test/Helpers/Routers/SpyRouter.swift new file mode 100644 index 0000000..7342a7d --- /dev/null +++ b/Libraries/Coordination/Test/Helpers/Routers/SpyRouter.swift @@ -0,0 +1,46 @@ +// +// SpyRouter.swift +// ReviewsCoordinationTest +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsCoordinationKit +import UIKit + +final class SpyRouter: Router { + + // MARK: Properties + var animated: Bool? + var onDismiss: OnDismissClosure? + var state: State = .initialised + var viewController: UIViewController? + + // MARK: Functions + func present( + _ viewController: UIViewController, + animated: Bool, + onDismiss: OnDismissClosure? + ) { + self.viewController = viewController + self.animated = animated + self.onDismiss = onDismiss + self.state = .presented + } + + func dismiss(animated: Bool) { + self.animated = animated + self.state = .dismissed + } + +} + +// MARK: - Enumerations +extension SpyRouter { + enum State { + case initialised + case presented + case dismissed + } +} diff --git a/Libraries/Coordination/Test/Helpers/TestViewControllers.swift b/Libraries/Coordination/Test/Helpers/TestViewControllers.swift new file mode 100644 index 0000000..1b9a1bb --- /dev/null +++ b/Libraries/Coordination/Test/Helpers/TestViewControllers.swift @@ -0,0 +1,12 @@ +// +// TestViewControllers.swift +// ReviewsCoordinationTests +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import UIKit + +final class SomeViewController: UIViewController {} +final class OtherViewController: UIViewController {} diff --git a/Libraries/Coordination/Test/Tests/Protocols/CoordinatorTests.swift b/Libraries/Coordination/Test/Tests/Protocols/CoordinatorTests.swift new file mode 100644 index 0000000..ea9af6a --- /dev/null +++ b/Libraries/Coordination/Test/Tests/Protocols/CoordinatorTests.swift @@ -0,0 +1,143 @@ +// +// CoordinatorTests.swift +// ReviewsCoordinationTest +// +// Created by Javier Cicchelli on 21/03/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import ReviewsCoordinationKit +import UIKit +import XCTest + +final class CoordinatorTests: XCTestCase { + + // MARK: Properties + private var router: SpyRouter! + private var coordinator: Coordinator! + + // MARK: Tests + func testPresent_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 testPresent_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 = OtherCoordinator(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 OtherViewController) + XCTAssertTrue(self.router.animated ?? false) + XCTAssertNotNil(self.router.onDismiss) + } + } + + func testPresentChild_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 = OtherCoordinator(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 OtherViewController) + XCTAssertFalse(self.router.animated ?? true) + XCTAssertNil(self.router.onDismiss) + } + } + + func testPresentChild_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 = OtherCoordinator(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 testDismiss_notAnimated() { + // GIVEN + router = SpyRouter() + coordinator = OtherCoordinator(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 testDismiss_animated() { + // GIVEN + router = SpyRouter() + coordinator = OtherCoordinator(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) + } + +} diff --git a/Libraries/Package.swift b/Libraries/Package.swift index 387f970..e0327a1 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library( name: .Product.name.kit, targets: [ + .Target.coordination.kit, .Target.feed.kit, .Target.filter.kit, .Target.foundation.kit, @@ -20,6 +21,13 @@ let package = Package( ), ], targets: [ + .target( + name: .Target.coordination.kit, + dependencies: [ + .byName(name: .Target.foundation.kit), + ], + path: "Coordination/Kit" + ), .target( name: .Target.feed.kit, dependencies: [ @@ -53,6 +61,13 @@ let package = Package( ], path: "UI/Kit" ), + .testTarget( + name: .Target.coordination.test, + dependencies: [ + .byName(name: .Target.coordination.kit), + ], + path: "Coordination/Test" + ), .testTarget( name: .Target.feed.test, dependencies: [ @@ -102,6 +117,7 @@ private extension String { } enum Target { + static let coordination = "\(String.Product.name)Coordination" static let feed = "\(String.Product.name)Feed" static let filter = "\(String.Product.name)Filter" static let foundation = "\(String.Product.name)Foundation"