From 2fffe99807f4d1809f22ea6b1177dbaee0e4f452 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 16:18:41 +0100 Subject: [PATCH 1/7] Created the Coordination library in the Libraries package. --- Libraries/Package.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Libraries/Package.swift b/Libraries/Package.swift index 387f970..ba29fe8 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.coordination.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" -- 2.47.1 From 1b4fde9adb8cec7b1435055fcdcb0687b87b0f02 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 16:26:00 +0100 Subject: [PATCH 2/7] Implemented the Router protocol in the Coordination library. --- .../Kit/Sources/Protocols/Router.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Libraries/Coordination/Kit/Sources/Protocols/Router.swift 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 + ) + } + +} -- 2.47.1 From 022f975d5ebcb3bc82fcc051d7d78453e380811a Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 16:26:11 +0100 Subject: [PATCH 3/7] Implemented the Coordinator protocol in the Coordination library. --- .../Kit/Sources/Protocols/Coordinator.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Libraries/Coordination/Kit/Sources/Protocols/Coordinator.swift 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 } + } + +} -- 2.47.1 From 6de4fca59eb6eed370924abefa61a15b9c1b29a5 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 17:35:12 +0100 Subject: [PATCH 4/7] Implemented some tests for the Coordinator protocol in the CoordinationTest library. --- .../Coordinators/OtherCoordinator.swift | 36 +++++ .../Coordinators/SomeCoordinator.swift | 36 +++++ .../Test/Helpers/Routers/SpyRouter.swift | 46 ++++++ .../Test/Helpers/TestViewControllers.swift | 12 ++ .../Tests/Protocols/CoordinatorTests.swift | 143 ++++++++++++++++++ Libraries/Package.swift | 2 +- 6 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 Libraries/Coordination/Test/Helpers/Coordinators/OtherCoordinator.swift create mode 100644 Libraries/Coordination/Test/Helpers/Coordinators/SomeCoordinator.swift create mode 100644 Libraries/Coordination/Test/Helpers/Routers/SpyRouter.swift create mode 100644 Libraries/Coordination/Test/Helpers/TestViewControllers.swift create mode 100644 Libraries/Coordination/Test/Tests/Protocols/CoordinatorTests.swift 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 ba29fe8..e0327a1 100644 --- a/Libraries/Package.swift +++ b/Libraries/Package.swift @@ -24,7 +24,7 @@ let package = Package( .target( name: .Target.coordination.kit, dependencies: [ - .byName(name: .Target.coordination.kit), + .byName(name: .Target.foundation.kit), ], path: "Coordination/Kit" ), -- 2.47.1 From 16de0feec6b576d045d60d0c48e1e4b203f5ea4c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 17:35:42 +0100 Subject: [PATCH 5/7] Implemented the WindowRouter router in the Coordination library. --- .../Kit/Sources/Routers/WindowRouter.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 Libraries/Coordination/Kit/Sources/Routers/WindowRouter.swift 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... + } + +} -- 2.47.1 From 09cd4342375f208ddbd46e62d5f8ba84642e87a1 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 17:38:01 +0100 Subject: [PATCH 6/7] Implemented the NavigationRouter router in the Coordination library. --- .../Sources/Routers/NavigationRouter.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Libraries/Coordination/Kit/Sources/Routers/NavigationRouter.swift 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) + } + +} -- 2.47.1 From bc8210e5483565a297fec145c8f171cf125eb668 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 21 Mar 2024 17:38:45 +0100 Subject: [PATCH 7/7] Implemented the PushRouter router in the Coordination library. --- .../Kit/Sources/Routers/PushRouter.swift | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 Libraries/Coordination/Kit/Sources/Routers/PushRouter.swift 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 + ) + } + +} -- 2.47.1