Difficulty: Beginner | Easy | Normal | Challenging
This article has been developed using Xcode 12.2, and Swift 5.3
Requires iOS13 and above for the implementation used to lock the view. The Repo is avaliable for download.
- You will be expected to be aware how to make a Single View Application in Swift.
- I have taken a programmatic approach to the interface, although this is unlikely to trip you up (the guide is here)
- This article uses Behaviour View Controllers
- This article uses my Network Library implementation
Architecture: The base structure of a software development project UIViewController: A view controller is an intermediary between the views it manages and the data of your app
I've previously covered the use of coordinators with MVVM and while that isn't required reading for this project, having a look at the repo would give you some idea as to the level of this article, and the motivation for writing it.
Although I am proud of that project structure, it can be challenging to keep the coordinators in sync with the view controller hierarchy
This article is intended to leverage the MVVM-C
architecture, and also provide a sample as to how the solution might be tested!
In order to keep this article, and the code as readable as possible I've used my own Network Library and Two Way Binding Library, and the links shown here allow you to look at my detailed explaination in those articles.
The project here has a login screen (which you'll need the username-password combination of eve.holt@reqres.in-cityslicka (provided by https://reqres.in/api/login
) and once logged in there is a UITableView
instance holding the data from https://jsonplaceholder.typicode.com/posts
and then a detail view that shows the same String
.
The example diagram is shown here:
Where we transition from the left-hand view controller to the right-hand side of the diagram. The UIViewController
instances are called LoginViewController
, ToDoListViewController
and DetailViewController
(in order from the diagram).
Since the project implements MVVM two-way binding has been implemented, and the data is stored in the ViewModel for each of these UIViewController
instances which are called LoginViewModel
, ToDoViewModel
and DetailViewModel
(again in order).
Encode Behaviors into Reusable View Controllers Dependency injection Data Binding using my Two Way Binding Library avoiding the use of third-party libraries Key Chain URLs are build using my URL Builder The views here are programatically created.
If you want to download the project from the Repo to get all of the code, you are welcome to. However since the login server is provided by https://reqres.in/api/login
to log in you need to use a username of eve.holt@reqres.in, and a password of cityslicka.
The token is stored from the login, and then is not used for future API requests. This is a function of using free API calls, and if you were to replace the API calls used it would be possible to retreive it using UserDataManager().token
/ keychain.token
and then use it in the client API for AnyNetworkManager
.
I'm using a programmatic way of creating the UIViewController
instances, and have not attempted to demonstrate how to use a UIStoryboard
in this project, although an approach from [this article covering MVVM Dependency Injection using Storyboards](Dependency Injection using Storyboards) could be adapted.
This is a demo project, and does not even attempt to obtain 100% test coverage, rather it covers the majority of areas and how they might be tested through a variety of test strategies.
In relevant view models we have an errorBindable
defined as:
var errorBindable: MakeBindable<Error> = MakeBindable()
which is observed from the relevant UIViewController
instance to a property, and the UIAlertView
is opened from there.
viewModel.errorBindable.bind(\Error.self, to: self, \.myError)
var myError: Error? {
didSet {
self.showNotification(title: "An error occured", message: "\(myError!.localizedDescription)", completion: nil)
}
}
This calls an extension that allows for a completion handler to communicate back to the UIViewController
instance that the user has finished with the UIAlertController
.
extension UIViewController {
func showNotification(title: String, message: String, completion: (() -> Void)?) {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let okAction: UIAlertAction = UIAlertAction.init(title: "OK", style: .default) { (_) in
guard let completion = completion else {return}
completion()
}
alertController.addAction(okAction)
present(alertController, animated: true, completion: nil)
}
}
In terms of dependency injection, a good use of initializers in the view model instances mean that the network manager and keychain manager can be used, and swapped out for mocks during testing (providing the mocks conform to NetworkManagerProtocol
and UserDataManagerProtocol
respectively.
init<T: NetworkManagerProtocol>(networkManager: T, keychain: UserDataManagerProtocol = UserDataManager() ) {
self.anyNetworkManager = AnyNetworkManager(manager: networkManager)
self.keychain = keychain
}
Then within tests a Mock can be used and injected using something like the following:
var loginViewModel: LoginViewModel?
var networkManager: MockNetworkManager<URLSession>?
loginViewModel = LoginViewModel(networkManager: networkManager!)
The login process is split between LoginViewController
and LoginViewModel
, as detailled here:
class LoginViewController: UIViewController {
private var viewModel: LoginViewModel!
let usernameTextField = UITextField(frame: .zero)
let passwordTextField = UITextField(frame: .zero)
private var coordinator: ProjectCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
setupBehaviours()
bind(to: viewModel)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
var traverse: Bool? {
didSet {
coordinator?.moveToList()
}
}
var myError: Error? {
didSet {
viewModel.cancelCall()
self.showNotification(title: "An error occured", message: "\(myError!.localizedDescription)", completion: nil)
}
}
lazy var loginButton: UIButton = {
let butt = UIButton(frame: .zero)
butt.setTitle("Login", for: .normal)
butt.setTitleColor(.blue, for: .normal)
butt.setTitleColor(.gray, for: .disabled)
butt.translatesAutoresizingMaskIntoConstraints = false
butt.addTarget(self, action: #selector(self.loginAction), for: .touchDown)
return butt
}()
lazy var controlsStack: UIStackView = {
let stack = UIStackView(frame: .zero)
stack.translatesAutoresizingMaskIntoConstraints = false
stack.distribution = .fillProportionally
stack.axis = .vertical
return stack
}()
@objc func loginAction() {
viewModel.loginNetworkCall()
}
override func loadView() {
let view = UIView()
view.backgroundColor = .white
self.view = view
self.view.addSubview(controlsStack)
usernameTextField.backgroundColor = .white
usernameTextField.layer.cornerRadius = 10
usernameTextField.layer.borderColor = UIColor.systemGray4.cgColor
usernameTextField.layer.borderWidth = 1.0
usernameTextField.placeholder = "username"
usernameTextField.autocapitalizationType = .none
usernameTextField.spellCheckingType = .no
usernameTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 15, height: usernameTextField.frame.height))
usernameTextField.leftViewMode = .always
usernameTextField.translatesAutoresizingMaskIntoConstraints = false
controlsStack.addArrangedSubview(usernameTextField)
passwordTextField.backgroundColor = .white
passwordTextField.layer.cornerRadius = 10
passwordTextField.layer.borderColor = UIColor.systemGray4.cgColor
passwordTextField.layer.borderWidth = 1.0
passwordTextField.placeholder = "password"
passwordTextField.autocapitalizationType = .none
passwordTextField.spellCheckingType = .no
passwordTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 15, height: passwordTextField.frame.height))
passwordTextField.leftViewMode = .always
passwordTextField.translatesAutoresizingMaskIntoConstraints = false
controlsStack.addArrangedSubview(passwordTextField)
controlsStack.addArrangedSubview(loginButton)
NSLayoutConstraint.activate([
usernameTextField.heightAnchor.constraint(equalToConstant: 50),
passwordTextField.heightAnchor.constraint(equalToConstant: 50),
controlsStack.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
controlsStack.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
controlsStack.heightAnchor.constraint(equalTo: self.view.heightAnchor, multiplier: 0.5),
controlsStack.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 0.8)
])
}
init(coordinator: ProjectCoordinator, viewModel: LoginViewModel) {
self.coordinator = coordinator
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func bind(to viewModel: LoginViewModel) {
viewModel.userNameBindable.bind(\String.self, to: usernameTextField, \.text)
viewModel.passwordBindable.bind(\String.self, to: passwordTextField, \.text)
viewModel.errorBindable.bind(\Error.self, to: self, \.myError)
viewModel.loginBindable.bind(\Bool.self, to: self, \.traverse)
viewModel.viewEnabledBindable.bind(\Bool.self, to: loginButton, \.isEnabled)
viewModel.viewEnabledBindable.bind(\Bool.self, to: self, \.loading)
}
var loading: Bool? {
didSet {
if let loading = loading, !loading {
UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.startIndicatingActivity()
} else {
UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.stopIndicatingActivity()
}
}
}
private func setupBehaviours() {
addBehaviors([HideNavigationBarBehavior()])
}
}
Whereas the LoginViewModel
makes the network request, and is bound to the UIViewController
instance:
import Foundation
import NetworkLibrary
import TwoWayBindingUIKit
class LoginViewModel {
var userNameBindable: MakeBindable<String> = MakeBindable() // "eve.holt@reqres.in"
var passwordBindable: MakeBindable<String> = MakeBindable() // "cityslicka"
var errorBindable: MakeBindable<Error> = MakeBindable()
var loginBindable: MakeBindable<Bool> = MakeBindable()
var viewEnabledBindable: MakeBindable<Bool> = MakeBindable()
private var anyNetworkManager: AnyNetworkManager<URLSession>
private var keychain: UserDataManagerProtocol
init<T: NetworkManagerProtocol>(networkManager: T, keychain: UserDataManagerProtocol = UserDataManager() ) {
self.anyNetworkManager = AnyNetworkManager(manager: networkManager)
self.keychain = keychain
}
func cancelCall() {
anyNetworkManager.cancel()
}
func loginNetworkCall() {
viewEnabledBindable.update(with: false)
guard let userName = userNameBindable.currentValue(),
let password = passwordBindable.currentValue()
else { self.viewEnabledBindable.update(with: true); return }
let data: [String : Any] = ["email": userName, "password": password]
anyNetworkManager.fetch(url: API.login.url, method: .post(body: data), completionBlock: {[weak self] res in
switch res {
case .success(let data):
let decoder = JSONDecoder()
if let decoded = try? decoder.decode(Login.self, from: data) {
self?.keychain.token = decoded.token
self?.loginBindable.update(with: true)
} else {
self?.errorBindable.update(with: ErrorModel(errorDescription: "Could not decode JSON"))
}
self?.viewEnabledBindable.update(with: true)
case .failure(let error):
self?.errorBindable.update(with: error)
self?.viewEnabledBindable.update(with: true)
}
})
}
}
The project coordinator is described through the following project:
If you look through the Repo I really think you’ll see how this functions, and perhaps how this can work in your own working context.
Good luck!
Flow Coordinators are a great way to prevent the classing "massive view controller" that we have all read about, while making sure that we have full functionality to aid understanding.
Remember you can download the Repo to get all of the code and, hopefully find this project useful for whatever you are doing in your coding journey.
Whatever you do with this project, and your time coding remember to do please enjoy it!
If you've any questions, comments or suggestions please hit me up on Twitter