From 39f671baa606038302a49d81ce0779f90df7ba67 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 02:45:30 +0200 Subject: [PATCH 1/6] Defined the Core library in the Libraries package for the Locations target. --- Apps/Locations/Libraries/Package.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Apps/Locations/Libraries/Package.swift b/Apps/Locations/Libraries/Package.swift index 6c5bf79..16ba787 100644 --- a/Apps/Locations/Libraries/Package.swift +++ b/Apps/Locations/Libraries/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library( name: "Libraries", targets: [ + "Core", "Dependency", "Locations", "Persistence" @@ -23,6 +24,10 @@ let package = Package( name: "APICore", dependencies: [] ), + .target( + name: "Core", + dependencies: [] + ), .target( name: "Dependency", dependencies: [] @@ -43,6 +48,12 @@ let package = Package( "APICore" ] ), + .testTarget( + name: "CoreTests", + dependencies: [ + "Core" + ] + ), .testTarget( name: "DependencyTests", dependencies: [ -- 2.47.1 From 2f9a2f78a3323e72a573d161fd400b8063fba9ab Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 14:23:58 +0200 Subject: [PATCH 2/6] Defined the Router protocol. --- .../Sources/Core/Protocols/Router.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift diff --git a/Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift b/Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift new file mode 100644 index 0000000..7264feb --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Core/Protocols/Router.swift @@ -0,0 +1,55 @@ +// +// Router.swift +// Core +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit + +/// This protocol defines how view controllers will be shown and dismissed. +public protocol Router: AnyObject { + + // MARK: Typealiases + + typealias OnDismissedClosure = () -> Void + + // MARK: Functions + + /// Present a view controller animatedly or not, dependencing 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? + ) + + /// Dismiss a view controller animatedly or not, dependencing 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) + +} + +// MARK: - Router+Implementations + +public extension Router { + + // MARK: Functions + + /// Present a view controller animatedly or not, dependencing 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 + ) + } + +} -- 2.47.1 From 49e2d18f14f49dc06e79629a1a91dd89bc9acd52 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 14:30:53 +0200 Subject: [PATCH 3/6] Defined the Coordinator protocol. --- .../Sources/Core/Protocols/Coordinator.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift diff --git a/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift b/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift new file mode 100644 index 0000000..8563606 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift @@ -0,0 +1,76 @@ +// +// Coordinator.swift +// Core +// +// 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, dependencing 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. + /// - onDismissed: A closure to be called or executed when the presented coordinator is dismissed. + func present(animated: Bool, onDismissed: Router.OnDismissedClosure?) + +} + +// MARK: - Coordinator+Implementations + +public extension Coordinator { + + /// Present a child coordinator animatedly or not, dependencing 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. + /// - onDismissed: A closure to be called or executed when the presented coordinator is dismissed. + func present( + child: Coordinator, + animated: Bool, + onDismissed: Router.OnDismissedClosure? = nil + ) { + store(child) + child.present(animated: animated) { [weak self, weak child] in + guard let self, let child else { + return + } + + self.free(child) + onDismissed?() + } + } + + /// Dismiss the coordinator animatedly or not, dependencing 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 } + } +} -- 2.47.1 From b76b36d6f1206dcd946546ccf937135d1de88432 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 15:01:44 +0200 Subject: [PATCH 4/6] Implemented the NavigationRouter router. --- .../Core/Routers/NavigationRouter.swift | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Apps/Locations/Libraries/Sources/Core/Routers/NavigationRouter.swift diff --git a/Apps/Locations/Libraries/Sources/Core/Routers/NavigationRouter.swift b/Apps/Locations/Libraries/Sources/Core/Routers/NavigationRouter.swift new file mode 100644 index 0000000..688c8ce --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Core/Routers/NavigationRouter.swift @@ -0,0 +1,105 @@ +// +// NavigationRouter.swift +// Core +// +// 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 NavigationRouter: NSObject { + + // MARK: Properties + + /// A navigation controller to use within this concrete router. + private let navigationController: UINavigationController + + /// A root view controller coming in from the navigation controller, if any. + private let rootViewController: UIViewController? + + /// Dictionary that persist `onDismiss` closure for its respective view controllers until one of the later is dismissed. + private var onDismissForViewController: [UIViewController: Router.OnDismissedClosure] = [:] + + // MARK: Initialisers + + /// Initialise this router. + /// - Parameter navigationController: A `UINavigationController` navigation controller instance to use in this router. + public init(navigationController: UINavigationController) { + self.navigationController = navigationController + self.rootViewController = navigationController.viewControllers.first + + super.init() + + self.navigationController.delegate = self + } + +} + +// MARK: - Router + +extension NavigationRouter: 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) + } + +} + +// MARK: - UINavigationControllerDelegate + +extension NavigationRouter: 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) + } + +} + +// MARK: - Helpers + +private extension NavigationRouter { + + // MARK: Functions + + func performOnDismissed(for viewController: UIViewController) { + guard let onDismiss = onDismissForViewController[viewController] else { + return + } + + onDismiss() + + onDismissForViewController[viewController] = nil + } + +} -- 2.47.1 From daf8bd7ff643638d95f216095f7cb07206f5a30d Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 15:03:39 +0200 Subject: [PATCH 5/6] Defined the View and the ViewModel protocols. --- .../Libraries/Sources/Core/Protocols/View.swift | 17 +++++++++++++++++ .../Sources/Core/Protocols/ViewModel.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 Apps/Locations/Libraries/Sources/Core/Protocols/View.swift create mode 100644 Apps/Locations/Libraries/Sources/Core/Protocols/ViewModel.swift diff --git a/Apps/Locations/Libraries/Sources/Core/Protocols/View.swift b/Apps/Locations/Libraries/Sources/Core/Protocols/View.swift new file mode 100644 index 0000000..43c0b70 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Core/Protocols/View.swift @@ -0,0 +1,17 @@ +// +// View.swift +// Core +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +/// This protocol defines the view of the **MVVM** architecture. +public protocol View { + + // MARK: Properties + + /// The view model related to the view. + var viewModel: ViewModel { get set } + +} diff --git a/Apps/Locations/Libraries/Sources/Core/Protocols/ViewModel.swift b/Apps/Locations/Libraries/Sources/Core/Protocols/ViewModel.swift new file mode 100644 index 0000000..a550329 --- /dev/null +++ b/Apps/Locations/Libraries/Sources/Core/Protocols/ViewModel.swift @@ -0,0 +1,17 @@ +// +// ViewModel.swift +// Core +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +/// This protocol defines the view model of the **MVVM** architecture. +public protocol ViewModel: AnyObject { + + // MARK: Properties + + /// The reference to the coordinator that initialised the view model. + var coordinator: Coordinator { get set } + +} -- 2.47.1 From 96da63b5556a641d18dba788bb122c75a6ca365f Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Tue, 11 Apr 2023 15:55:01 +0200 Subject: [PATCH 6/6] Implemented some test cases for the Coordinator protocol. --- .../Sources/Core/Protocols/Coordinator.swift | 10 +- .../CoreTests/Helpers/TestCoordinators.swift | 106 ++++++++++++++ .../Protocols/CoordinatorTests.swift | 138 ++++++++++++++++++ 3 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 Apps/Locations/Libraries/Tests/CoreTests/Helpers/TestCoordinators.swift create mode 100644 Apps/Locations/Libraries/Tests/CoreTests/Protocols/CoordinatorTests.swift diff --git a/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift b/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift index 8563606..e7ebaa2 100644 --- a/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift +++ b/Apps/Locations/Libraries/Sources/Core/Protocols/Coordinator.swift @@ -22,8 +22,8 @@ public protocol Coordinator: AnyObject { /// Present the coordinator animatedly or not, dependencing 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. - /// - onDismissed: A closure to be called or executed when the presented coordinator is dismissed. - func present(animated: Bool, onDismissed: Router.OnDismissedClosure?) + /// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed. + func present(animated: Bool, onDismiss: Router.OnDismissedClosure?) } @@ -35,11 +35,11 @@ public extension Coordinator { /// - Parameters: /// - child: A child coordinator to be presented. /// - animated: A boolean that represents whether the coordinator should be dismissed animatedly or not. - /// - onDismissed: A closure to be called or executed when the presented coordinator is dismissed. + /// - onDismiss: A closure to be called or executed when the presented coordinator is dismissed. func present( child: Coordinator, animated: Bool, - onDismissed: Router.OnDismissedClosure? = nil + onDismiss: Router.OnDismissedClosure? = nil ) { store(child) child.present(animated: animated) { [weak self, weak child] in @@ -48,7 +48,7 @@ public extension Coordinator { } self.free(child) - onDismissed?() + onDismiss?() } } diff --git a/Apps/Locations/Libraries/Tests/CoreTests/Helpers/TestCoordinators.swift b/Apps/Locations/Libraries/Tests/CoreTests/Helpers/TestCoordinators.swift new file mode 100644 index 0000000..3db996e --- /dev/null +++ b/Apps/Locations/Libraries/Tests/CoreTests/Helpers/TestCoordinators.swift @@ -0,0 +1,106 @@ +// +// TestCoordinators.swift +// CoreTests +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import Core +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/Apps/Locations/Libraries/Tests/CoreTests/Protocols/CoordinatorTests.swift b/Apps/Locations/Libraries/Tests/CoreTests/Protocols/CoordinatorTests.swift new file mode 100644 index 0000000..57bb5fe --- /dev/null +++ b/Apps/Locations/Libraries/Tests/CoreTests/Protocols/CoordinatorTests.swift @@ -0,0 +1,138 @@ +// +// CoordinatorTests.swift +// CoreTests +// +// Created by Javier Cicchelli on 11/04/2023. +// Copyright © 2023 Röck+Cöde. All rights reserved. +// + +import UIKit +import XCTest + +@testable import Core + +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